diff options
author | Jacob Vosmaer <jacob@gitlab.com> | 2016-08-09 12:27:37 +0200 |
---|---|---|
committer | Jacob Vosmaer <jacob@gitlab.com> | 2016-08-09 12:27:37 +0200 |
commit | 7a99826694ccc9dc5fd5f8cbecf7b51f8d690de4 (patch) | |
tree | e58828158e6a818e82a0a7cda95b642998233d77 | |
parent | 71952d057d5edad0697d7da76f5da034689e0f4a (diff) | |
parent | 551ffc0a4d25a381e9f8f6a8d6f2793bb87f3145 (diff) | |
download | gitlab-ce-7a99826694ccc9dc5fd5f8cbecf7b51f8d690de4.tar.gz |
Merge branch 'master' of https://gitlab.com/gitlab-org/gitlab-ce into remove-grack-lfs
905 files changed, 25477 insertions, 17535 deletions
diff --git a/.gitattributes b/.gitattributes index 7e800609e6c..17cbaa5eef5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ -CHANGELOG merge=union
\ No newline at end of file +CHANGELOG merge=union +*.js.es6 gitlab-language=javascript diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2d33bad5886..8da9acf9066 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,6 +28,7 @@ stages: - prepare - test - post-test +- pages # Prepare and merge knapsack tests .knapsack-state: &knapsack-state @@ -40,6 +41,7 @@ stages: paths: - knapsack/ artifacts: + expire_in: 31d paths: - knapsack/ @@ -81,8 +83,10 @@ update-knapsack: - cp knapsack/rspec_report.json ${KNAPSACK_REPORT_PATH} - knapsack rspec artifacts: + expire_in: 31d paths: - knapsack/ + - coverage/ .spinach-knapsack: &spinach-knapsack stage: test @@ -97,8 +101,10 @@ update-knapsack: - cp knapsack/spinach_report.json ${KNAPSACK_REPORT_PATH} - knapsack spinach "-r rerun" || retry '[ ! -e tmp/spinach-rerun.txt ] || bundle exec spinach -r rerun $(cat tmp/spinach-rerun.txt)' artifacts: + expire_in: 31d paths: - knapsack/ + - coverage/ rspec 0 20: *rspec-knapsack rspec 1 20: *rspec-knapsack @@ -186,14 +192,14 @@ spinach 9 10 ruby23: *spinach-knapsack-ruby23 # Other generic tests -.static-analyses-variables: &static-analyses-variables +.ruby-static-analysis: &ruby-static-analysis variables: SIMPLECOV: "false" USE_DB: "false" USE_BUNDLE_INSTALL: "true" .exec: &exec - <<: *static-analyses-variables + <<: *ruby-static-analysis stage: test script: - bundle exec $CI_BUILD_NAME @@ -220,16 +226,35 @@ teaspoon: bundler:audit: stage: test - <<: *static-analyses-variables + <<: *ruby-static-analysis only: - master script: - "bundle exec bundle-audit check --update --ignore OSVDB-115941" +coverage: + stage: post-test + services: [] + variables: + USE_DB: "false" + USE_BUNDLE_INSTALL: "true" + script: + - bundle exec scripts/merge-simplecov + artifacts: + name: coverage + expire_in: 31d + paths: + - coverage/index.html + - coverage/assets/ + + # Notify slack in the end notify:slack: stage: post-test + variables: + USE_DB: "false" + USE_BUNDLE_INSTALL: "false" script: - ./scripts/notify_slack.sh "#builds" "Build on \`$CI_BUILD_REF_NAME\` failed! Commit \`$(git log -1 --oneline)\` See <https://gitlab.com/gitlab-org/$(basename "$PWD")/commit/"$CI_BUILD_REF"/builds>" when: on_failure @@ -238,3 +263,18 @@ notify:slack: - tags@gitlab-org/gitlab-ce - master@gitlab-org/gitlab-ee - tags@gitlab-org/gitlab-ee + +pages: + before_script: [] + stage: pages + dependencies: + - coverage + script: + - mv public/ .public/ + - mkdir public/ + - mv coverage public/coverage-ruby + artifacts: + paths: + - public + only: + - master diff --git a/.rubocop.yml b/.rubocop.yml index 6adbda53456..282f4539f03 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -149,19 +149,19 @@ Style/EmptyLinesAroundAccessModifier: # Keeps track of empty lines around block bodies. Style/EmptyLinesAroundBlockBody: - Enabled: false + Enabled: true # Keeps track of empty lines around class bodies. Style/EmptyLinesAroundClassBody: - Enabled: false + Enabled: true # Keeps track of empty lines around module bodies. Style/EmptyLinesAroundModuleBody: - Enabled: false + Enabled: true # Keeps track of empty lines around method bodies. Style/EmptyLinesAroundMethodBody: - Enabled: false + Enabled: true # Avoid the use of END blocks. Style/EndBlock: @@ -373,6 +373,10 @@ Style/SpaceAfterNot: Style/SpaceAfterSemicolon: Enabled: true +# Use space around equals in parameter default +Style/SpaceAroundEqualsInParameterDefault: + Enabled: true + # Use a space around keywords if appropriate. Style/SpaceAroundKeyword: Enabled: true @@ -510,6 +514,15 @@ Metrics/PerceivedComplexity: #################### Lint ################################ +# Checks for useless access modifiers. +Lint/UselessAccessModifier: + Enabled: true + +# Checks for attempts to use `private` or `protected` to set the visibility +# of a class method, which does not work. +Lint/IneffectiveAccessModifier: + Enabled: false + # Checks for ambiguous operators in the first argument of a method invocation # without parentheses. Lint/AmbiguousOperator: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index b622b9239d4..20daf1619a7 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -19,10 +19,6 @@ Lint/AssignmentInCondition: Lint/HandleExceptions: Enabled: false -# Offense count: 21 -Lint/IneffectiveAccessModifier: - Enabled: false - # Offense count: 2 Lint/Loop: Enabled: false @@ -48,10 +44,6 @@ Lint/UnusedBlockArgument: Lint/UnusedMethodArgument: Enabled: false -# Offense count: 11 -Lint/UselessAccessModifier: - Enabled: false - # Offense count: 12 # Cop supports --auto-correct. Performance/PushSplat: @@ -347,13 +339,6 @@ Style/SingleLineBlockParams: Style/SingleLineMethods: Enabled: false -# Offense count: 14 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: space, no_space -Style/SpaceAroundEqualsInParameterDefault: - Enabled: false - # Offense count: 119 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. diff --git a/.simplecov b/.simplecov deleted file mode 100644 index d979288df44..00000000000 --- a/.simplecov +++ /dev/null @@ -1,4 +0,0 @@ -# .simplecov -SimpleCov.start 'rails' do - merge_timeout 3600 -end diff --git a/CHANGELOG b/CHANGELOG index e2104338f5c..7bfeff2a4ec 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,9 +1,135 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.11.0 (unreleased) + - Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres) + - Fix rename `add_users_into_project` and `projects_ids`. !20512 (herminiotorres) + - Fix the title of the toggle dropdown button. !5515 (herminiotorres) + - Improve diff performance by eliminating redundant checks for text blobs + - Convert switch icon into icon font (ClemMakesApps) + - API: Endpoints for enabling and disabling deploy keys + - Remove magic comments (`# encoding: UTF-8`) from Ruby files. !5456 (winniehell) + - Add support for relative links starting with ./ or / to RelativeLinkFilter (winniehell) + - Ignore URLs starting with // in Markdown links !5677 (winniehell) + - Fix CI status icon link underline (ClemMakesApps) + - The Repository class is now instrumented + - Cache the commit author in RequestStore to avoid extra lookups in PostReceive + - Expand commit message width in repo view (ClemMakesApps) + - Cache highlighted diff lines for merge requests - Fix of 'Commits being passed to custom hooks are already reachable when using the UI' + - Add support for using RequestStore within Sidekiq tasks via SIDEKIQ_REQUEST_STORE env variable + - Optimize maximum user access level lookup in loading of notes + - Add "No one can push" as an option for protected branches. !5081 + - Improve performance of AutolinkFilter#text_parse by using XPath + - Environments have an url to link to + - Update `timeago` plugin to use multiple string/locale settings + - Remove unused images (ClemMakesApps) - Limit git rev-list output count to one in forced push check + - Clean up unused routes (Josef Strzibny) + - Fix issue on empty project to allow developers to only push to protected branches if given permission + - Add green outline to New Branch button. !5447 (winniehell) + - Optimize generating of cache keys for issues and notes + - Improve performance of syntax highlighting Markdown code blocks + - Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects + - Remove delay when hitting "Reply..." button on page with a lot of discussions - Retrieve rendered HTML from cache in one request + - Fix renaming repository when name contains invalid chararacters under project settings + - Fix devise deprecation warnings. + - Update version_sorter and use new interface for faster tag sorting + - Optimize checking if a user has read access to a list of issues !5370 + - Nokogiri's various parsing methods are now instrumented + - Add simple identifier to public SSH keys (muteor) + - Admin page now references docs instead of a specific file !5600 (AnAverageHuman) + - Add a way to send an email and create an issue based on private personal token. Find the email address from issues page. !3363 + - Fix filter input alignment (ClemMakesApps) + - Include old revision in merge request update hooks (Ben Boeckel) + - Add build event color in HipChat messages (David Eisner) + - Make fork counter always clickable. !5463 (winniehell) + - Document that webhook secret token is sent in X-Gitlab-Token HTTP header !5664 (lycoperdon) + - Gitlab::Highlight is now instrumented + - All created issues, API or WebUI, can be submitted to Akismet for spam check !5333 + - The overhead of instrumented method calls has been reduced + - Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le) + - Load project invited groups and members eagerly in `ProjectTeam#fetch_members` + - Bump gitlab_git to speedup DiffCollection iterations + - Rewrite description of a blocked user in admin settings. (Elias Werberich) + - Make branches sortable without push permission !5462 (winniehell) + - Check for Ci::Build artifacts at database level on pipeline partial + - Convert image diff background image to CSS (ClemMakesApps) + - Remove unnecessary index_projects_on_builds_enabled index from the projects table + - Make "New issue" button in Issue page less obtrusive !5457 (winniehell) + - Gitlab::Metrics.current_transaction needs to be public for RailsQueueDuration + - Fix search for notes which belongs to deleted objects + - Add GitLab Workhorse version to admin dashboard (Katarzyna Kobierska Ula Budziszewska) + - Allow branch names ending with .json for graph and network page !5579 (winniehell) + - Add the `sprockets-es6` gem + - Multiple trigger variables show in separate lines (Katarzyna Kobierska Ula Budziszewska) + - Profile requests when a header is passed + - Avoid calculation of line_code and position for _line partial when showing diff notes on discussion tab. + - Speedup DiffNote#active? on discussions, preloading noteables and avoid touching git repository to return diff_refs when possible + - Add commit stats in commit api. !5517 (dixpac) + - Add CI configuration button on project page + - Make error pages responsive (Takuya Noguchi) + - Fix skip_repo parameter being ignored when destroying a namespace + - Change requests_profiles resource constraint to catch virtually any file + - Bump gitlab_git to lazy load compare commits + - Reduce number of queries made for merge_requests/:id/diffs + - Sensible state specific default sort order for issues and merge requests !5453 (tomb0y) + - Fix RequestProfiler::Middleware error when code is reloaded in development + - Catch what warden might throw when profiling requests to re-throw it + - Add description to new_issue email and new_merge_request_email in text/plain content type. !5663 (dixpac) + - Speed up and reduce memory usage of Commit#repo_changes, Repository#expire_avatar_cache and IrkerWorker + - Add unfold links for Side-by-Side view. !5415 (Tim Masliuchenko) + - Adds support for pending invitation project members importing projects + - Update devise initializer to turn on changed password notification emails. !5648 (tombell) + - Avoid to show the original password field when password is automatically set. !5712 (duduribeiro) + +v 8.10.5 (unreleased) + +v 8.10.4 + - Don't close referenced upstream issues from a forked project. + - Fixes issue with dropdowns `enter` key not working correctly. !5544 + - Fix Import/Export project import not working in HA mode. !5618 + - Fix Import/Export error checking versions. !5638 + +v 8.10.3 + - Fix Import/Export issue importing milestones and labels not associated properly. !5426 + - Fix timing problems running imports on production. !5523 + - Add a log message when a project is scheduled for destruction for debugging. !5540 + - Fix hooks missing on imported GitLab projects. !5549 + - Properly abort a merge when merge conflicts occur. !5569 + - Fix importer for GitHub Pull Requests when a branch was removed. !5573 + - Ignore invalid IPs in X-Forwarded-For when trusted proxies are configured. !5584 + - Trim extra displayed carriage returns in diffs and files with CRLFs. !5588 + +v 8.10.2 + - User can now search branches by name. !5144 + - Page is now properly rendered after committing the first file and creating the first branch. !5399 + - Add branch or tag icon to ref in builds page. !5434 + - Fix backup restore. !5459 + - Use project ID in repository cache to prevent stale data from persisting across projects. !5460 + - Fix issue with autocomplete search not working with enter key. !5466 + - Add iid to MR API response. !5468 + - Disable MySQL foreign key checks before dropping all tables. !5472 + - Ensure relative paths for video are rewritten as we do for images. !5474 + - Ensure current user can retry a build before showing the 'Retry' button. !5476 + - Add ENV variable to skip repository storages validations. !5478 + - Added `*.js.es6 gitlab-language=javascript` to `.gitattributes`. !5486 + - Don't show comment button in gutter of diffs on MR discussion tab. !5493 + - Rescue Rugged::OSError (lock exists) when creating references. !5497 + - Fix expand all diffs button in compare view. !5500 + - Show release notes in tags list. !5503 + - Fix a bug where forking a project from a repository storage to another would fail. !5509 + - Fix missing schema update for `20160722221922`. !5512 + - Update `gitlab-shell` version to 3.2.1 in the 8.9->8.10 update guide. !5516 + +v 8.10.1 + - Refactor repository storages documentation. !5428 + - Gracefully handle case when keep-around references are corrupted or exist already. !5430 + - Add detailed info on storage path mountpoints. !5437 + - Fix Error 500 when creating Wiki pages with hyphens or spaces. !5444 + - Fix bug where replies to commit notes displayed in the MR discussion tab wouldn't show up on the commit page. !5446 + - Ignore invalid trusted proxies in X-Forwarded-For header. !5454 + - Add links to the real markdown.md file for all GFM examples. !5458 v 8.10.0 - Fix profile activity heatmap to show correct day name (eanplatter) @@ -52,6 +178,9 @@ v 8.10.0 - Fix check for New Branch button on Issue page. !4630 (winniehell) - Fix GFM autocomplete not working on wiki pages - Fixed enter key not triggering click on first row when searching in a dropdown + - Updated dropdowns in issuable form to use new GitLab dropdown style + - Make images fit to the size of the viewport !4810 + - Fix check for New Branch button on Issue page !4630 (winniehell) - Fix MR-auto-close text added to description. !4836 - Support U2F devices in Firefox. !5177 - Fix issue, preventing users w/o push access to sort tags. !5105 (redetection) @@ -82,7 +211,6 @@ v 8.10.0 - API: Todos. !3188 (Robert Schilling) - API: Expose shared groups for projects and shared projects for groups. !5050 (Robert Schilling) - API: Expose `developers_can_push` and `developers_can_merge` for branches. !5208 (Robert Schilling) - - Update to gitlab_git 10.4.1 and take advantage of preserved Ref objects - Add "Enabled Git access protocols" to Application Settings - Diffs will create button/diff form on demand no on server side - Reduce size of HTML used by diff comment forms diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14ff05c9aa3..fbc8e15bebf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,8 @@ abbreviation. If you have read this guide and want to know how the GitLab [core team] operates please see [the GitLab contributing process](PROCESS.md). +- [GitLab Inc engineers should refer to the engineering workflow document](https://about.gitlab.com/handbook/engineering/workflow/) + ## Contributor license agreement By submitting code as an individual you agree to the @@ -334,6 +336,10 @@ request is as follows: 1. If your code creates new files on disk please read the [shared files guidelines](doc/development/shared_files.md). 1. When writing commit messages please follow [these](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) [guidelines](http://chris.beams.io/posts/git-commit/). +1. If your merge request adds one or more migrations, make sure to execute all + migrations on a fresh database before the MR is reviewed. If the review leads + to large changes in the MR, do this again once the review is complete. +1. For more complex migrations, write tests. The **official merge window** is in the beginning of the month from the 1st to the 7th day of the month. This is the best time to submit an MR and get @@ -459,8 +465,10 @@ merge request: - multi-line method chaining style **Option B**: dot `.` on previous line - 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. [CoffeeScript](https://github.com/thoughtbot/guides/tree/master/style/coffeescript) +1. [JavaScript (ES6)](https://github.com/airbnb/javascript) +1. [JavaScript (ES5)](https://github.com/airbnb/javascript/tree/master/es5) 1. [SCSS styleguide][scss-styleguide] 1. [Shell commands](doc/development/shell_commands.md) created by GitLab contributors to enhance security @@ -530,6 +538,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [rss-naming]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#naming [doc-styleguide]: doc/development/doc_styleguide.md "Documentation styleguide" [scss-styleguide]: doc/development/scss_styleguide.md "SCSS styleguide" +[newlines-styleguide]: doc/development/newlines_styleguide.md "Newlines styleguide" [gitlab-design]: https://gitlab.com/gitlab-org/gitlab-design [free Antetype viewer (Mac OSX only)]: https://itunes.apple.com/us/app/antetype-viewer/id824152298?mt=12 [`gitlab8.atype` file]: https://gitlab.com/gitlab-org/gitlab-design/tree/master/current/ diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 944880fa15e..e4604e3afd0 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -3.2.0 +3.2.1 @@ -9,6 +9,7 @@ gem 'responders', '~> 2.0' # Specify a sprockets version due to increased performance # See https://gitlab.com/gitlab-org/gitlab-ce/issues/6069 gem 'sprockets', '~> 3.6.0' +gem 'sprockets-es6' # Default values for AR models gem 'default_value_for', '~> 3.0.0' @@ -52,7 +53,7 @@ gem 'browser', '~> 2.2' # Extracting information from a git repository # Provide access to Gitlab::Git library -gem 'gitlab_git', '~> 10.4.1' +gem 'gitlab_git', '~> 10.4.5' # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes @@ -153,7 +154,7 @@ gem 'settingslogic', '~> 2.0.9' # Misc -gem 'version_sorter', '~> 2.0.0' +gem 'version_sorter', '~> 2.1.0' # Cache gem 'redis-rails', '~> 4.0.0' @@ -224,7 +225,7 @@ gem 'addressable', '~> 2.3.8' gem 'bootstrap-sass', '~> 3.3.0' gem 'font-awesome-rails', '~> 4.6.1' gem 'gemojione', '~> 3.0' -gem 'gon', '~> 6.0.1' +gem 'gon', '~> 6.1.0' gem 'jquery-atwho-rails', '~> 1.3.2' gem 'jquery-rails', '~> 4.1.0' gem 'jquery-ui-rails', '~> 5.0.0' @@ -252,7 +253,7 @@ group :development do gem 'letter_opener_web', '~> 1.3.0' gem 'rerun', '~> 0.11.0' - gem 'bullet', '~> 5.0.0', require: false + gem 'bullet', '~> 5.2.0', require: false gem 'rblineprof', '~> 0.3.6', platform: :mri, require: false gem 'web-console', '~> 2.0' @@ -274,7 +275,7 @@ group :development, :test do gem 'awesome_print', '~> 1.2.0', require: false gem 'fuubar', '~> 2.0.0' - gem 'database_cleaner', '~> 1.4.0' + gem 'database_cleaner', '~> 1.5.0' gem 'factory_girl_rails', '~> 4.6.0' gem 'rspec-rails', '~> 3.5.0' gem 'rspec-retry', '~> 0.4.5' @@ -302,7 +303,7 @@ group :development, :test do gem 'rubocop', '~> 0.41.2', require: false gem 'rubocop-rspec', '~> 1.5.0', require: false gem 'scss_lint', '~> 0.47.0', require: false - gem 'simplecov', '~> 0.11.0', require: false + gem 'simplecov', '0.12.0', require: false gem 'flog', '~> 4.3.2', require: false gem 'flay', '~> 2.6.1', require: false gem 'bundler-audit', '~> 0.5.0', require: false @@ -325,7 +326,7 @@ group :production do gem 'gitlab_meta', '7.0' end -gem 'newrelic_rpm', '~> 3.14' +gem 'newrelic_rpm', '~> 3.16' gem 'octokit', '~> 4.3.0' @@ -333,6 +334,8 @@ gem 'mail_room', '~> 0.8' gem 'email_reply_parser', '~> 0.5.8' +gem 'ruby-prof', '~> 0.15.9' + ## CI gem 'activerecord-session_store', '~> 1.0.0' gem 'nested_form', '~> 0.3.2' diff --git a/Gemfile.lock b/Gemfile.lock index 195516d1bf1..87f08d6f372 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -59,7 +59,7 @@ GEM oauth2 (~> 1.0) asciidoctor (1.5.3) ast (2.3.0) - attr_encrypted (3.0.1) + attr_encrypted (3.0.3) encryptor (~> 3.0.0) attr_required (1.0.0) autoprefixer-rails (6.2.3) @@ -85,6 +85,10 @@ GEM faraday (~> 0.9) faraday_middleware (~> 0.10) nokogiri (~> 1.6) + babel-source (5.8.35) + babel-transpiler (0.7.0) + babel-source (>= 4.0, < 6) + execjs (~> 2.0) babosa (1.0.2) base32 (0.3.2) bcrypt (3.1.11) @@ -100,9 +104,9 @@ GEM brakeman (3.3.2) browser (2.2.0) builder (3.2.2) - bullet (5.0.0) + bullet (5.2.0) activesupport (>= 3.0.0) - uniform_notifier (~> 1.9.0) + uniform_notifier (~> 1.10.0) bundler-audit (0.5.0) bundler (~> 1.2) thor (~> 0.18) @@ -149,11 +153,11 @@ GEM d3_rails (3.5.11) railties (>= 3.1.0) daemons (1.2.3) - database_cleaner (1.4.1) + database_cleaner (1.5.3) debug_inspector (0.0.2) debugger-ruby_core_source (1.3.8) - default_value_for (3.0.1) - activerecord (>= 3.2.0, < 5.0) + default_value_for (3.0.2) + activerecord (>= 3.2.0, < 5.1) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) devise (4.1.1) @@ -274,7 +278,7 @@ GEM diff-lcs (~> 1.1) mime-types (>= 1.16, < 3) posix-spawn (~> 0.3) - gitlab_git (10.4.1) + gitlab_git (10.4.5) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) @@ -299,7 +303,7 @@ GEM gollum-rugged_adapter (0.4.2) mime-types (>= 1.15) rugged (~> 0.24.0, >= 0.21.3) - gon (6.0.1) + gon (6.1.0) actionpack (>= 3.0) json multi_json @@ -400,7 +404,7 @@ GEM nested_form (0.3.2) net-ldap (0.12.1) net-ssh (3.0.1) - newrelic_rpm (3.14.1.311) + newrelic_rpm (3.16.0.318) nokogiri (1.6.8) mini_portile2 (~> 2.1.0) pkg-config (~> 1.1.7) @@ -505,7 +509,7 @@ GEM rack-cors (0.4.0) rack-mount (0.8.3) rack (>= 1.0.0) - rack-oauth2 (1.2.1) + rack-oauth2 (1.2.3) activesupport (>= 2.3) attr_required (>= 0.0.5) httpclient (>= 2.4) @@ -571,7 +575,7 @@ GEM redis-store (~> 1.1.0) redis-store (1.1.7) redis (>= 2.2) - request_store (1.3.0) + request_store (1.3.1) rerun (0.11.0) listen (~> 3.0) responders (2.1.1) @@ -616,6 +620,7 @@ GEM rubocop (>= 0.40.0) ruby-fogbugz (0.2.1) crack (~> 0.4) + ruby-prof (0.15.9) ruby-progressbar (1.8.1) ruby-saml (1.3.0) nokogiri (>= 1.5.10) @@ -668,9 +673,9 @@ GEM rufus-scheduler (>= 2.0.24) sidekiq (>= 4.0.0) simple_oauth (0.1.9) - simplecov (0.11.2) + simplecov (0.12.0) docile (~> 1.1.0) - json (~> 1.8) + json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.0) sinatra (1.4.7) @@ -700,6 +705,10 @@ GEM sprockets (3.6.3) concurrent-ruby (~> 1.0) rack (> 1, < 3) + sprockets-es6 (0.9.0) + babel-source (>= 5.8.11) + babel-transpiler + sprockets (>= 3.0.0) sprockets-rails (3.1.1) actionpack (>= 4.0) activesupport (>= 4.0) @@ -766,10 +775,10 @@ GEM unicorn-worker-killer (0.4.4) get_process_mem (~> 0) unicorn (>= 4, < 6) - uniform_notifier (1.9.0) + uniform_notifier (1.10.0) uuid (2.3.8) macaddr (~> 1.0) - version_sorter (2.0.0) + version_sorter (2.1.0) virtus (1.0.5) axiom-types (~> 0.1) coercible (~> 1.0) @@ -821,7 +830,7 @@ DEPENDENCIES bootstrap-sass (~> 3.3.0) brakeman (~> 3.3.0) browser (~> 2.2) - bullet (~> 5.0.0) + bullet (~> 5.2.0) bundler-audit (~> 0.5.0) byebug (~> 8.2.1) capybara (~> 2.6.2) @@ -833,7 +842,7 @@ DEPENDENCIES connection_pool (~> 2.0) creole (~> 0.5.0) d3_rails (~> 3.5.0) - database_cleaner (~> 1.4.0) + database_cleaner (~> 1.5.0) default_value_for (~> 3.0.0) devise (~> 4.0) devise-two-factor (~> 3.0.0) @@ -861,12 +870,12 @@ DEPENDENCIES github-linguist (~> 4.7.0) github-markup (~> 1.4) gitlab-flowdock-git-hook (~> 1.0.1) - gitlab_git (~> 10.4.1) + gitlab_git (~> 10.4.5) gitlab_meta (= 7.0) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.2) gollum-rugged_adapter (~> 0.4.2) - gon (~> 6.0.1) + gon (~> 6.1.0) grape (~> 0.13.0) grape-entity (~> 0.4.2) hamlit (~> 2.5) @@ -893,7 +902,7 @@ DEPENDENCIES mysql2 (~> 0.3.16) nested_form (~> 0.3.2) net-ssh (~> 3.0.1) - newrelic_rpm (~> 3.14) + newrelic_rpm (~> 3.16) nokogiri (~> 1.6.7, >= 1.6.7.2) oauth2 (~> 1.2.0) octokit (~> 4.3.0) @@ -940,6 +949,7 @@ DEPENDENCIES rubocop (~> 0.41.2) rubocop-rspec (~> 1.5.0) ruby-fogbugz (~> 0.2.1) + ruby-prof (~> 0.15.9) sanitize (~> 2.0) sass-rails (~> 5.0.0) scss_lint (~> 0.47.0) @@ -952,7 +962,7 @@ DEPENDENCIES shoulda-matchers (~> 2.8.0) sidekiq (~> 4.0) sidekiq-cron (~> 0.4.0) - simplecov (~> 0.11.0) + simplecov (= 0.12.0) sinatra (~> 1.4.4) six (~> 0.2.0) slack-notifier (~> 1.2.0) @@ -963,6 +973,7 @@ DEPENDENCIES spring-commands-spinach (~> 1.1.0) spring-commands-teaspoon (~> 0.0.2) sprockets (~> 3.6.0) + sprockets-es6 state_machines-activerecord (~> 0.4.0) sys-filesystem (~> 1.1.6) task_list (~> 1.0.2) @@ -978,7 +989,7 @@ DEPENDENCIES unf (~> 0.1.4) unicorn (~> 4.9.0) unicorn-worker-killer (~> 0.4.2) - version_sorter (~> 2.0.0) + version_sorter (~> 2.1.0) virtus (~> 1.0.1) vmstat (~> 2.1.1) web-console (~> 2.0) diff --git a/PROCESS.md b/PROCESS.md index fe3a963110d..8e1a3f7360f 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -8,6 +8,8 @@ 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.). +- [GitLab Inc engineers should refer to the engineering workflow document](https://about.gitlab.com/handbook/engineering/workflow/) + ## Common actions ### Issue team diff --git a/app/assets/images/bg-header.png b/app/assets/images/bg-header.png Binary files differdeleted file mode 100644 index 639271c6faf..00000000000 --- a/app/assets/images/bg-header.png +++ /dev/null diff --git a/app/assets/images/bg_fallback.png b/app/assets/images/bg_fallback.png Binary files differdeleted file mode 100644 index 5c55bc79dec..00000000000 --- a/app/assets/images/bg_fallback.png +++ /dev/null diff --git a/app/assets/images/chosen-sprite.png b/app/assets/images/chosen-sprite.png Binary files differdeleted file mode 100644 index 3d936b07d44..00000000000 --- a/app/assets/images/chosen-sprite.png +++ /dev/null diff --git a/app/assets/images/diff_note_add.png b/app/assets/images/diff_note_add.png Binary files differdeleted file mode 100644 index 0084422e330..00000000000 --- a/app/assets/images/diff_note_add.png +++ /dev/null diff --git a/app/assets/images/icon-search.png b/app/assets/images/icon-search.png Binary files differdeleted file mode 100644 index 3c1c146541d..00000000000 --- a/app/assets/images/icon-search.png +++ /dev/null diff --git a/app/assets/images/icon_sprite.png b/app/assets/images/icon_sprite.png Binary files differdeleted file mode 100644 index 2e7a5023398..00000000000 --- a/app/assets/images/icon_sprite.png +++ /dev/null diff --git a/app/assets/images/images.png b/app/assets/images/images.png Binary files differdeleted file mode 100644 index bd60de994c4..00000000000 --- a/app/assets/images/images.png +++ /dev/null diff --git a/app/assets/images/move.png b/app/assets/images/move.png Binary files differdeleted file mode 100644 index 6a0567f8f25..00000000000 --- a/app/assets/images/move.png +++ /dev/null diff --git a/app/assets/images/progress_bar.gif b/app/assets/images/progress_bar.gif Binary files differdeleted file mode 100644 index c3d43fa40b2..00000000000 --- a/app/assets/images/progress_bar.gif +++ /dev/null diff --git a/app/assets/images/slider_handles.png b/app/assets/images/slider_handles.png Binary files differdeleted file mode 100644 index 52ad11ab7a1..00000000000 --- a/app/assets/images/slider_handles.png +++ /dev/null diff --git a/app/assets/images/switch_icon.png b/app/assets/images/switch_icon.png Binary files differdeleted file mode 100644 index c6b6c8d9521..00000000000 --- a/app/assets/images/switch_icon.png +++ /dev/null diff --git a/app/assets/images/trans_bg.gif b/app/assets/images/trans_bg.gif Binary files differdeleted file mode 100644 index 1a1c9c15ec7..00000000000 --- a/app/assets/images/trans_bg.gif +++ /dev/null diff --git a/app/assets/javascripts/LabelManager.js b/app/assets/javascripts/LabelManager.js new file mode 100644 index 00000000000..151455ce4a3 --- /dev/null +++ b/app/assets/javascripts/LabelManager.js @@ -0,0 +1,110 @@ +(function() { + this.LabelManager = (function() { + LabelManager.prototype.errorMessage = 'Unable to update label prioritization at this time'; + + function LabelManager(opts) { + var ref, ref1, ref2; + if (opts == null) { + opts = {}; + } + this.togglePriorityButton = (ref = opts.togglePriorityButton) != null ? ref : $('.js-toggle-priority'), this.prioritizedLabels = (ref1 = opts.prioritizedLabels) != null ? ref1 : $('.js-prioritized-labels'), this.otherLabels = (ref2 = opts.otherLabels) != null ? ref2 : $('.js-other-labels'); + this.prioritizedLabels.sortable({ + items: 'li', + placeholder: 'list-placeholder', + axis: 'y', + update: this.onPrioritySortUpdate.bind(this) + }); + this.bindEvents(); + } + + LabelManager.prototype.bindEvents = function() { + return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick); + }; + + LabelManager.prototype.onTogglePriorityClick = function(e) { + var $btn, $label, $tooltip, _this, action; + e.preventDefault(); + _this = e.data; + $btn = $(e.currentTarget); + $label = $("#" + ($btn.data('domId'))); + action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add'; + $tooltip = $("#" + ($btn.find('.has-tooltip:visible').attr('aria-describedby'))); + $tooltip.tooltip('destroy'); + return _this.toggleLabelPriority($label, action); + }; + + LabelManager.prototype.toggleLabelPriority = function($label, action, persistState) { + var $from, $target, _this, url, xhr; + if (persistState == null) { + persistState = true; + } + _this = this; + url = $label.find('.js-toggle-priority').data('url'); + $target = this.prioritizedLabels; + $from = this.otherLabels; + if (action === 'remove') { + $target = this.otherLabels; + $from = this.prioritizedLabels; + } + if ($from.find('li').length === 1) { + $from.find('.empty-message').removeClass('hidden'); + } + if (!$target.find('li').length) { + $target.find('.empty-message').addClass('hidden'); + } + $label.detach().appendTo($target); + if (!persistState) { + return; + } + if (action === 'remove') { + xhr = $.ajax({ + url: url, + type: 'DELETE' + }); + if (!$from.find('li').length) { + $from.find('.empty-message').removeClass('hidden'); + } + } else { + xhr = this.savePrioritySort($label, action); + } + return xhr.fail(this.rollbackLabelPosition.bind(this, $label, action)); + }; + + LabelManager.prototype.onPrioritySortUpdate = function() { + var xhr; + xhr = this.savePrioritySort(); + return xhr.fail(function() { + return new Flash(this.errorMessage, 'alert'); + }); + }; + + LabelManager.prototype.savePrioritySort = function() { + return $.post({ + url: this.prioritizedLabels.data('url'), + data: { + label_ids: this.getSortedLabelsIds() + } + }); + }; + + LabelManager.prototype.rollbackLabelPosition = function($label, originalAction) { + var action; + action = originalAction === 'remove' ? 'add' : 'remove'; + this.toggleLabelPriority($label, action, false); + return new Flash(this.errorMessage, 'alert'); + }; + + LabelManager.prototype.getSortedLabelsIds = function() { + var sortedIds; + sortedIds = []; + this.prioritizedLabels.find('li').each(function() { + return sortedIds.push($(this).data('id')); + }); + return sortedIds; + }; + + return LabelManager; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/LabelManager.js.coffee b/app/assets/javascripts/LabelManager.js.coffee deleted file mode 100644 index 6d8faba40d7..00000000000 --- a/app/assets/javascripts/LabelManager.js.coffee +++ /dev/null @@ -1,92 +0,0 @@ -class @LabelManager - errorMessage: 'Unable to update label prioritization at this time' - - constructor: (opts = {}) -> - # Defaults - { - @togglePriorityButton = $('.js-toggle-priority') - @prioritizedLabels = $('.js-prioritized-labels') - @otherLabels = $('.js-other-labels') - } = opts - - @prioritizedLabels.sortable( - items: 'li' - placeholder: 'list-placeholder' - axis: 'y' - update: @onPrioritySortUpdate.bind(@) - ) - - @bindEvents() - - bindEvents: -> - @togglePriorityButton.on 'click', @, @onTogglePriorityClick - - onTogglePriorityClick: (e) -> - e.preventDefault() - _this = e.data - $btn = $(e.currentTarget) - $label = $("##{$btn.data('domId')}") - action = if $btn.parents('.js-prioritized-labels').length then 'remove' else 'add' - - # Make sure tooltip will hide - $tooltip = $ "##{$btn.find('.has-tooltip:visible').attr('aria-describedby')}" - $tooltip.tooltip 'destroy' - - _this.toggleLabelPriority($label, action) - - toggleLabelPriority: ($label, action, persistState = true) -> - _this = @ - url = $label.find('.js-toggle-priority').data 'url' - - $target = @prioritizedLabels - $from = @otherLabels - - # Optimistic update - if action is 'remove' - $target = @otherLabels - $from = @prioritizedLabels - - if $from.find('li').length is 1 - $from.find('.empty-message').removeClass('hidden') - - if not $target.find('li').length - $target.find('.empty-message').addClass('hidden') - - $label.detach().appendTo($target) - - # Return if we are not persisting state - return unless persistState - - if action is 'remove' - xhr = $.ajax url: url, type: 'DELETE' - - # Restore empty message - $from.find('.empty-message').removeClass('hidden') unless $from.find('li').length - else - xhr = @savePrioritySort($label, action) - - xhr.fail @rollbackLabelPosition.bind(@, $label, action) - - onPrioritySortUpdate: -> - xhr = @savePrioritySort() - - xhr.fail -> - new Flash(@errorMessage, 'alert') - - savePrioritySort: () -> - $.post - url: @prioritizedLabels.data('url') - data: - label_ids: @getSortedLabelsIds() - - rollbackLabelPosition: ($label, originalAction)-> - action = if originalAction is 'remove' then 'add' else 'remove' - @toggleLabelPriority($label, action, false) - - new Flash(@errorMessage, 'alert') - - getSortedLabelsIds: -> - sortedIds = [] - @prioritizedLabels.find('li').each -> - sortedIds.push $(@).data 'id' - sortedIds diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js new file mode 100644 index 00000000000..1ab3c2197d8 --- /dev/null +++ b/app/assets/javascripts/activities.js @@ -0,0 +1,40 @@ +(function() { + this.Activities = (function() { + function Activities() { + Pager.init(20, true, false, this.updateTooltips); + $(".event-filter-link").on("click", (function(_this) { + return function(event) { + event.preventDefault(); + _this.toggleFilter($(event.currentTarget)); + return _this.reloadActivities(); + }; + })(this)); + } + + Activities.prototype.updateTooltips = function() { + return gl.utils.localTimeAgo($('.js-timeago', '#activity')); + }; + + Activities.prototype.reloadActivities = function() { + $(".content_list").html(''); + return Pager.init(20, true); + }; + + Activities.prototype.toggleFilter = function(sender) { + var event_filters, filter; + $('.event-filter .active').removeClass("active"); + event_filters = $.cookie("event_filter"); + filter = sender.attr("id").split("_")[0]; + $.cookie("event_filter", (event_filters !== filter ? filter : ""), { + path: '/' + }); + if (event_filters !== filter) { + return sender.closest('li').toggleClass("active"); + } + }; + + return Activities; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/activities.js.coffee b/app/assets/javascripts/activities.js.coffee deleted file mode 100644 index ed5a5d0260c..00000000000 --- a/app/assets/javascripts/activities.js.coffee +++ /dev/null @@ -1,24 +0,0 @@ -class @Activities - constructor: -> - Pager.init 20, true, false, @updateTooltips - $(".event-filter-link").on "click", (event) => - event.preventDefault() - @toggleFilter($(event.currentTarget)) - @reloadActivities() - - updateTooltips: -> - gl.utils.localTimeAgo($('.js-timeago', '#activity')) - - reloadActivities: -> - $(".content_list").html '' - Pager.init 20, true - - - toggleFilter: (sender) -> - $('.event-filter .active').removeClass "active" - event_filters = $.cookie("event_filter") - filter = sender.attr("id").split("_")[0] - $.cookie "event_filter", (if event_filters isnt filter then filter else ""), { path: '/' } - - if event_filters isnt filter - sender.closest('li').toggleClass "active" diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js new file mode 100644 index 00000000000..f8460beb5d2 --- /dev/null +++ b/app/assets/javascripts/admin.js @@ -0,0 +1,64 @@ +(function() { + this.Admin = (function() { + function Admin() { + var modal, showBlacklistType; + $('input#user_force_random_password').on('change', function(elem) { + var elems; + elems = $('#user_password, #user_password_confirmation'); + if ($(this).attr('checked')) { + return elems.val('').attr('disabled', true); + } else { + return elems.removeAttr('disabled'); + } + }); + $('body').on('click', '.js-toggle-colors-link', function(e) { + e.preventDefault(); + return $('.js-toggle-colors-container').toggle(); + }); + $('.log-tabs a').click(function(e) { + e.preventDefault(); + return $(this).tab('show'); + }); + $('.log-bottom').click(function(e) { + var visible_log; + e.preventDefault(); + visible_log = $(".file-content:visible"); + return visible_log.animate({ + scrollTop: visible_log.find('ol').height() + }, "fast"); + }); + modal = $('.change-owner-holder'); + $('.change-owner-link').bind("click", function(e) { + e.preventDefault(); + $(this).hide(); + return modal.show(); + }); + $('.change-owner-cancel-link').bind("click", function(e) { + e.preventDefault(); + modal.hide(); + return $('.change-owner-link').show(); + }); + $('li.project_member').bind('ajax:success', function() { + return Turbolinks.visit(location.href); + }); + $('li.group_member').bind('ajax:success', function() { + return Turbolinks.visit(location.href); + }); + showBlacklistType = function() { + if ($("input[name='blacklist_type']:checked").val() === 'file') { + $('.blacklist-file').show(); + return $('.blacklist-raw').hide(); + } else { + $('.blacklist-file').hide(); + return $('.blacklist-raw').show(); + } + }; + $("input[name='blacklist_type']").click(showBlacklistType); + showBlacklistType(); + } + + return Admin; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/admin.js.coffee b/app/assets/javascripts/admin.js.coffee deleted file mode 100644 index 90c09619f8c..00000000000 --- a/app/assets/javascripts/admin.js.coffee +++ /dev/null @@ -1,51 +0,0 @@ -class @Admin - constructor: -> - $('input#user_force_random_password').on 'change', (elem) -> - elems = $('#user_password, #user_password_confirmation') - - if $(@).attr 'checked' - elems.val('').attr 'disabled', true - else - elems.removeAttr 'disabled' - - $('body').on 'click', '.js-toggle-colors-link', (e) -> - e.preventDefault() - $('.js-toggle-colors-container').toggle() - - $('.log-tabs a').click (e) -> - e.preventDefault() - $(this).tab('show') - - $('.log-bottom').click (e) -> - e.preventDefault() - visible_log = $(".file-content:visible") - visible_log.animate({ scrollTop: visible_log.find('ol').height() }, "fast") - - modal = $('.change-owner-holder') - - $('.change-owner-link').bind "click", (e) -> - e.preventDefault() - $(this).hide() - modal.show() - - $('.change-owner-cancel-link').bind "click", (e) -> - e.preventDefault() - modal.hide() - $('.change-owner-link').show() - - $('li.project_member').bind 'ajax:success', -> - Turbolinks.visit(location.href) - - $('li.group_member').bind 'ajax:success', -> - Turbolinks.visit(location.href) - - showBlacklistType = -> - if $("input[name='blacklist_type']:checked").val() == 'file' - $('.blacklist-file').show() - $('.blacklist-raw').hide() - else - $('.blacklist-file').hide() - $('.blacklist-raw').show() - - $("input[name='blacklist_type']").click showBlacklistType - showBlacklistType() diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js new file mode 100644 index 00000000000..49c2ac0dac3 --- /dev/null +++ b/app/assets/javascripts/api.js @@ -0,0 +1,136 @@ +(function() { + this.Api = { + groupsPath: "/api/:version/groups.json", + groupPath: "/api/:version/groups/:id.json", + namespacesPath: "/api/:version/namespaces.json", + groupProjectsPath: "/api/:version/groups/:id/projects.json", + projectsPath: "/api/:version/projects.json?simple=true", + labelsPath: "/api/:version/projects/:id/labels", + licensePath: "/api/:version/licenses/:key", + gitignorePath: "/api/:version/gitignores/:key", + gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key", + group: function(group_id, callback) { + var url; + url = Api.buildUrl(Api.groupPath); + url = url.replace(':id', group_id); + return $.ajax({ + url: url, + data: { + private_token: gon.api_token + }, + dataType: "json" + }).done(function(group) { + return callback(group); + }); + }, + groups: function(query, skip_ldap, callback) { + var url; + url = Api.buildUrl(Api.groupsPath); + return $.ajax({ + url: url, + data: { + private_token: gon.api_token, + search: query, + per_page: 20 + }, + dataType: "json" + }).done(function(groups) { + return callback(groups); + }); + }, + namespaces: function(query, callback) { + var url; + url = Api.buildUrl(Api.namespacesPath); + return $.ajax({ + url: url, + data: { + private_token: gon.api_token, + search: query, + per_page: 20 + }, + dataType: "json" + }).done(function(namespaces) { + return callback(namespaces); + }); + }, + projects: function(query, order, callback) { + var url; + url = Api.buildUrl(Api.projectsPath); + return $.ajax({ + url: url, + data: { + private_token: gon.api_token, + search: query, + order_by: order, + per_page: 20 + }, + dataType: "json" + }).done(function(projects) { + return callback(projects); + }); + }, + newLabel: function(project_id, data, callback) { + var url; + url = Api.buildUrl(Api.labelsPath); + url = url.replace(':id', project_id); + data.private_token = gon.api_token; + return $.ajax({ + url: url, + type: "POST", + data: data, + dataType: "json" + }).done(function(label) { + return callback(label); + }).error(function(message) { + return callback(message.responseJSON); + }); + }, + groupProjects: function(group_id, query, callback) { + var url; + url = Api.buildUrl(Api.groupProjectsPath); + url = url.replace(':id', group_id); + return $.ajax({ + url: url, + data: { + private_token: gon.api_token, + search: query, + per_page: 20 + }, + dataType: "json" + }).done(function(projects) { + return callback(projects); + }); + }, + licenseText: function(key, data, callback) { + var url; + url = Api.buildUrl(Api.licensePath).replace(':key', key); + return $.ajax({ + url: url, + data: data + }).done(function(license) { + return callback(license); + }); + }, + gitignoreText: function(key, callback) { + var url; + url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + return $.get(url, function(gitignore) { + return callback(gitignore); + }); + }, + gitlabCiYml: function(key, callback) { + var url; + url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + return $.get(url, function(file) { + return callback(file); + }); + }, + buildUrl: function(url) { + if (gon.relative_url_root != null) { + url = gon.relative_url_root + url; + } + return url.replace(':version', gon.api_version); + } + }; + +}).call(this); diff --git a/app/assets/javascripts/api.js.coffee b/app/assets/javascripts/api.js.coffee deleted file mode 100644 index 89b0ac697ed..00000000000 --- a/app/assets/javascripts/api.js.coffee +++ /dev/null @@ -1,122 +0,0 @@ -@Api = - groupsPath: "/api/:version/groups.json" - groupPath: "/api/:version/groups/:id.json" - namespacesPath: "/api/:version/namespaces.json" - groupProjectsPath: "/api/:version/groups/:id/projects.json" - projectsPath: "/api/:version/projects.json?simple=true" - labelsPath: "/api/:version/projects/:id/labels" - licensePath: "/api/:version/licenses/:key" - gitignorePath: "/api/:version/gitignores/:key" - gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key" - - group: (group_id, callback) -> - url = Api.buildUrl(Api.groupPath) - url = url.replace(':id', group_id) - - $.ajax( - url: url - data: - private_token: gon.api_token - dataType: "json" - ).done (group) -> - callback(group) - - # Return groups list. Filtered by query - # Only active groups retrieved - groups: (query, skip_ldap, callback) -> - url = Api.buildUrl(Api.groupsPath) - - $.ajax( - url: url - data: - private_token: gon.api_token - search: query - per_page: 20 - dataType: "json" - ).done (groups) -> - callback(groups) - - # Return namespaces list. Filtered by query - namespaces: (query, callback) -> - url = Api.buildUrl(Api.namespacesPath) - - $.ajax( - url: url - data: - private_token: gon.api_token - search: query - per_page: 20 - dataType: "json" - ).done (namespaces) -> - callback(namespaces) - - # Return projects list. Filtered by query - projects: (query, order, callback) -> - url = Api.buildUrl(Api.projectsPath) - - $.ajax( - url: url - data: - private_token: gon.api_token - search: query - order_by: order - per_page: 20 - dataType: "json" - ).done (projects) -> - callback(projects) - - newLabel: (project_id, data, callback) -> - url = Api.buildUrl(Api.labelsPath) - url = url.replace(':id', project_id) - - data.private_token = gon.api_token - $.ajax( - url: url - type: "POST" - data: data - dataType: "json" - ).done (label) -> - callback(label) - .error (message) -> - callback(message.responseJSON) - - # Return group projects list. Filtered by query - groupProjects: (group_id, query, callback) -> - url = Api.buildUrl(Api.groupProjectsPath) - url = url.replace(':id', group_id) - - $.ajax( - url: url - data: - private_token: gon.api_token - search: query - per_page: 20 - dataType: "json" - ).done (projects) -> - callback(projects) - - # Return text for a specific license - licenseText: (key, data, callback) -> - url = Api.buildUrl(Api.licensePath).replace(':key', key) - - $.ajax( - url: url - data: data - ).done (license) -> - callback(license) - - gitignoreText: (key, callback) -> - url = Api.buildUrl(Api.gitignorePath).replace(':key', key) - - $.get url, (gitignore) -> - callback(gitignore) - - gitlabCiYml: (key, callback) -> - url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key) - - $.get url, (file) -> - callback(file) - - buildUrl: (url) -> - url = gon.relative_url_root + url if gon.relative_url_root? - return url.replace(':version', gon.api_version) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 00000000000..f1aab067351 --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,322 @@ +/*= require jquery2 */ +/*= require jquery-ui/autocomplete */ +/*= require jquery-ui/datepicker */ +/*= require jquery-ui/draggable */ +/*= require jquery-ui/effect-highlight */ +/*= require jquery-ui/sortable */ +/*= require jquery_ujs */ +/*= require jquery.cookie */ +/*= require jquery.endless-scroll */ +/*= require jquery.highlight */ +/*= require jquery.waitforimages */ +/*= require jquery.atwho */ +/*= require jquery.scrollTo */ +/*= require jquery.turbolinks */ +/*= require turbolinks */ +/*= require autosave */ +/*= require bootstrap/affix */ +/*= require bootstrap/alert */ +/*= require bootstrap/button */ +/*= require bootstrap/collapse */ +/*= require bootstrap/dropdown */ +/*= require bootstrap/modal */ +/*= require bootstrap/scrollspy */ +/*= require bootstrap/tab */ +/*= require bootstrap/transition */ +/*= require bootstrap/tooltip */ +/*= require bootstrap/popover */ +/*= require select2 */ +/*= require ace/ace */ +/*= require ace/ext-searchbox */ +/*= require underscore */ +/*= require dropzone */ +/*= require mousetrap */ +/*= require mousetrap/pause */ +/*= require shortcuts */ +/*= require shortcuts_navigation */ +/*= require shortcuts_dashboard_navigation */ +/*= require shortcuts_issuable */ +/*= require shortcuts_network */ +/*= require jquery.nicescroll */ +/*= require date.format */ +/*= require_directory ./behaviors */ +/*= require_directory ./blob */ +/*= require_directory ./commit */ +/*= require_directory ./extensions */ +/*= require_directory ./lib/utils */ +/*= require_directory ./u2f */ +/*= require_directory . */ +/*= require fuzzaldrin-plus */ + +(function() { + window.slugify = function(text) { + return text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase(); + }; + + window.ajaxGet = function(url) { + return $.ajax({ + type: "GET", + url: url, + dataType: "script" + }); + }; + + window.split = function(val) { + return val.split(/,\s*/); + }; + + window.extractLast = function(term) { + return split(term).pop(); + }; + + window.rstrip = function(val) { + if (val) { + return val.replace(/\s+$/, ''); + } else { + return val; + } + }; + + window.disableButtonIfEmptyField = function(field_selector, button_selector) { + var closest_submit, field; + field = $(field_selector); + closest_submit = field.closest('form').find(button_selector); + if (rstrip(field.val()) === "") { + closest_submit.disable(); + } + return field.on('input', function() { + if (rstrip($(this).val()) === "") { + return closest_submit.disable(); + } else { + return closest_submit.enable(); + } + }); + }; + + window.disableButtonIfAnyEmptyField = function(form, form_selector, button_selector) { + var closest_submit, updateButtons; + closest_submit = form.find(button_selector); + updateButtons = function() { + var filled; + filled = true; + form.find('input').filter(form_selector).each(function() { + return filled = rstrip($(this).val()) !== "" || !$(this).attr('required'); + }); + if (filled) { + return closest_submit.enable(); + } else { + return closest_submit.disable(); + } + }; + updateButtons(); + return form.keyup(updateButtons); + }; + + window.sanitize = function(str) { + return str.replace(/<(?:.|\n)*?>/gm, ''); + }; + + window.unbindEvents = function() { + return $(document).off('scroll'); + }; + + window.shiftWindow = function() { + return scrollBy(0, -100); + }; + + document.addEventListener("page:fetch", unbindEvents); + + window.addEventListener("hashchange", shiftWindow); + + window.onload = function() { + if (location.hash) { + return setTimeout(shiftWindow, 100); + } + }; + + $(function() { + var $body, $document, $sidebarGutterToggle, $window, bootstrapBreakpoint, checkInitialSidebarSize, fitSidebarForSize, flash; + $document = $(document); + $window = $(window); + $body = $('body'); + gl.utils.preventDisabledButtons(); + bootstrapBreakpoint = bp.getBreakpointSize(); + $(".nav-sidebar").niceScroll({ + cursoropacitymax: '0.4', + cursorcolor: '#FFF', + cursorborder: "1px solid #FFF" + }); + $(".js-select-on-focus").on("focusin", function() { + return $(this).select().one('mouseup', function(e) { + return e.preventDefault(); + }); + }); + $('.remove-row').bind('ajax:success', function() { + return $(this).closest('li').fadeOut(); + }); + $('.js-remove-tr').bind('ajax:before', function() { + return $(this).hide(); + }); + $('.js-remove-tr').bind('ajax:success', function() { + return $(this).closest('tr').fadeOut(); + }); + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true + }); + $('.js-select2').bind('select2-close', function() { + return setTimeout((function() { + $('.select2-container-active').removeClass('select2-container-active'); + return $(':focus').blur(); + }), 1); + }); + $body.tooltip({ + selector: '.has-tooltip, [data-toggle="tooltip"]', + placement: function(_, el) { + var $el; + $el = $(el); + return $el.data('placement') || 'bottom'; + } + }); + $('.trigger-submit').on('change', function() { + return $(this).parents('form').submit(); + }); + gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), true); + if ((flash = $(".flash-container")).length > 0) { + flash.click(function() { + return $(this).fadeOut(); + }); + flash.show(); + } + $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function(e) { + var buttons; + buttons = $('[type="submit"]', this); + switch (e.type) { + case 'ajax:beforeSend': + case 'submit': + return buttons.disable(); + default: + return buttons.enable(); + } + }); + $(document).ajaxError(function(e, xhrObj, xhrSetting, xhrErrorText) { + var ref; + if (xhrObj.status === 401) { + return new Flash('You need to be logged in.', 'alert'); + } else if ((ref = xhrObj.status) === 404 || ref === 500) { + return new Flash('Something went wrong on our end.', 'alert'); + } + }); + $('.account-box').hover(function() { + return $(this).toggleClass('hover'); + }); + $document.on('click', '.diff-content .js-show-suppressed-diff', function() { + var $container; + $container = $(this).parent(); + $container.next('table').show(); + return $container.remove(); + }); + $('.navbar-toggle').on('click', function() { + $('.header-content .title').toggle(); + $('.header-content .header-logo').toggle(); + $('.header-content .navbar-collapse').toggle(); + return $('.navbar-toggle').toggleClass('active'); + }); + $body.on("click", ".js-toggle-diff-comments", function(e) { + $(this).toggleClass('active'); + $(this).closest(".diff-file").find(".notes_holder").toggle(); + return e.preventDefault(); + }); + $document.off("click", '.js-confirm-danger'); + $document.on("click", '.js-confirm-danger', function(e) { + var btn, form, text; + e.preventDefault(); + btn = $(e.target); + text = btn.data("confirm-danger-message"); + form = btn.closest("form"); + return new ConfirmDangerModal(form, text); + }); + $document.on('click', 'button', function() { + return $(this).blur(); + }); + $('input[type="search"]').each(function() { + var $this; + $this = $(this); + $this.attr('value', $this.val()); + }); + $document.off('keyup', 'input[type="search"]').on('keyup', 'input[type="search"]', function(e) { + var $this; + $this = $(this); + return $this.attr('value', $this.val()); + }); + $sidebarGutterToggle = $('.js-sidebar-toggle'); + $document.off('breakpoint:change').on('breakpoint:change', function(e, breakpoint) { + var $gutterIcon; + if (breakpoint === 'sm' || breakpoint === 'xs') { + $gutterIcon = $sidebarGutterToggle.find('i'); + if ($gutterIcon.hasClass('fa-angle-double-right')) { + return $sidebarGutterToggle.trigger('click'); + } + } + }); + fitSidebarForSize = function() { + var oldBootstrapBreakpoint; + oldBootstrapBreakpoint = bootstrapBreakpoint; + bootstrapBreakpoint = bp.getBreakpointSize(); + if (bootstrapBreakpoint !== oldBootstrapBreakpoint) { + return $document.trigger('breakpoint:change', [bootstrapBreakpoint]); + } + }; + checkInitialSidebarSize = function() { + bootstrapBreakpoint = bp.getBreakpointSize(); + if (bootstrapBreakpoint === "xs" || "sm") { + return $document.trigger('breakpoint:change', [bootstrapBreakpoint]); + } + }; + $window.off("resize.app").on("resize.app", function(e) { + return fitSidebarForSize(); + }); + gl.awardsHandler = new AwardsHandler(); + checkInitialSidebarSize(); + new Aside(); + if ($window.width() < 1024 && $.cookie('pin_nav') === 'true') { + $.cookie('pin_nav', 'false', { + path: '/', + expires: 365 * 10 + }); + $('.page-with-sidebar').toggleClass('page-sidebar-collapsed page-sidebar-expanded').removeClass('page-sidebar-pinned'); + $('.navbar-fixed-top').removeClass('header-pinned-nav'); + } + $document.off('click', '.js-nav-pin').on('click', '.js-nav-pin', function(e) { + var $page, $pinBtn, $tooltip, $topNav, doPinNav, tooltipText; + e.preventDefault(); + $pinBtn = $(e.currentTarget); + $page = $('.page-with-sidebar'); + $topNav = $('.navbar-fixed-top'); + $tooltip = $("#" + ($pinBtn.attr('aria-describedby'))); + doPinNav = !$page.is('.page-sidebar-pinned'); + tooltipText = 'Pin navigation'; + $(this).toggleClass('is-active'); + if (doPinNav) { + $page.addClass('page-sidebar-pinned'); + $topNav.addClass('header-pinned-nav'); + } else { + $tooltip.remove(); + $page.removeClass('page-sidebar-pinned').toggleClass('page-sidebar-collapsed page-sidebar-expanded'); + $topNav.removeClass('header-pinned-nav').toggleClass('header-collapsed header-expanded'); + } + $.cookie('pin_nav', doPinNav, { + path: '/', + expires: 365 * 10 + }); + if ($.cookie('pin_nav') === 'true' || doPinNav) { + tooltipText = 'Unpin navigation'; + } + $tooltip.find('.tooltip-inner').text(tooltipText); + return $pinBtn.attr('title', tooltipText).tooltip('fixTitle'); + }); + + // Custom time ago + gl.utils.shortTimeAgo($('.js-short-timeago')); + }); +}).call(this); diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee deleted file mode 100644 index eceff6d91d5..00000000000 --- a/app/assets/javascripts/application.js.coffee +++ /dev/null @@ -1,310 +0,0 @@ -# This is a manifest file that'll be compiled into including all the files listed below. -# Add new JavaScript/Coffee code in separate files in this directory and they'll automatically -# be included in the compiled file accessible from http://example.com/assets/application.js -# It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -# the compiled file. -# -#= require jquery2 -#= require jquery-ui/autocomplete -#= require jquery-ui/datepicker -#= require jquery-ui/draggable -#= require jquery-ui/effect-highlight -#= require jquery-ui/sortable -#= require jquery_ujs -#= require jquery.cookie -#= require jquery.endless-scroll -#= require jquery.highlight -#= require jquery.waitforimages -#= require jquery.atwho -#= require jquery.scrollTo -#= require jquery.turbolinks -#= require turbolinks -#= require autosave -#= require bootstrap/affix -#= require bootstrap/alert -#= require bootstrap/button -#= require bootstrap/collapse -#= require bootstrap/dropdown -#= require bootstrap/modal -#= require bootstrap/scrollspy -#= require bootstrap/tab -#= require bootstrap/transition -#= require bootstrap/tooltip -#= require bootstrap/popover -#= require select2 -#= require ace/ace -#= require ace/ext-searchbox -#= require underscore -#= require dropzone -#= require mousetrap -#= require mousetrap/pause -#= require shortcuts -#= require shortcuts_navigation -#= require shortcuts_dashboard_navigation -#= require shortcuts_issuable -#= require shortcuts_network -#= require jquery.nicescroll -#= require date.format -#= require_directory ./behaviors -#= require_directory ./blob -#= require_directory ./commit -#= require_directory ./extensions -#= require_directory ./lib/utils -#= require_directory ./u2f -#= require_directory . -#= require fuzzaldrin-plus - -window.slugify = (text) -> - text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() - -window.ajaxGet = (url) -> - $.ajax({type: "GET", url: url, dataType: "script"}) - -window.split = (val) -> - return val.split( /,\s*/ ) - -window.extractLast = (term) -> - return split( term ).pop() - -window.rstrip = (val) -> - return if val then val.replace(/\s+$/, '') else val - -# Disable button if text field is empty -window.disableButtonIfEmptyField = (field_selector, button_selector) -> - field = $(field_selector) - closest_submit = field.closest('form').find(button_selector) - - closest_submit.disable() if rstrip(field.val()) is "" - - field.on 'input', -> - if rstrip($(@).val()) is "" - closest_submit.disable() - else - closest_submit.enable() - -# Disable button if any input field with given selector is empty -window.disableButtonIfAnyEmptyField = (form, form_selector, button_selector) -> - closest_submit = form.find(button_selector) - updateButtons = -> - filled = true - form.find('input').filter(form_selector).each -> - filled = rstrip($(this).val()) != "" || !$(this).attr('required') - - if filled - closest_submit.enable() - else - closest_submit.disable() - - updateButtons() - form.keyup(updateButtons) - -window.sanitize = (str) -> - return str.replace(/<(?:.|\n)*?>/gm, '') - -window.unbindEvents = -> - $(document).off('scroll') - -window.shiftWindow = -> - scrollBy 0, -100 - -document.addEventListener("page:fetch", unbindEvents) - -window.addEventListener "hashchange", shiftWindow - -window.onload = -> - # Scroll the window to avoid the topnav bar - # https://github.com/twitter/bootstrap/issues/1768 - if location.hash - setTimeout shiftWindow, 100 - -$ -> - - $document = $(document) - $window = $(window) - $body = $('body') - - gl.utils.preventDisabledButtons() - bootstrapBreakpoint = bp.getBreakpointSize() - - $(".nav-sidebar").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") - - # Click a .js-select-on-focus field, select the contents - $(".js-select-on-focus").on "focusin", -> - # Prevent a mouseup event from deselecting the input - $(this).select().one 'mouseup', (e) -> - e.preventDefault() - - $('.remove-row').bind 'ajax:success', -> - $(this).closest('li').fadeOut() - - $('.js-remove-tr').bind 'ajax:before', -> - $(this).hide() - - $('.js-remove-tr').bind 'ajax:success', -> - $(this).closest('tr').fadeOut() - - # Initialize select2 selects - $('select.select2').select2(width: 'resolve', dropdownAutoWidth: true) - - # Close select2 on escape - $('.js-select2').bind 'select2-close', -> - setTimeout ( -> - $('.select2-container-active').removeClass('select2-container-active') - $(':focus').blur() - ), 1 - - # Initialize tooltips - $body.tooltip( - selector: '.has-tooltip, [data-toggle="tooltip"]' - placement: (_, el) -> - $el = $(el) - $el.data('placement') || 'bottom' - ) - - # Form submitter - $('.trigger-submit').on 'change', -> - $(@).parents('form').submit() - - gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), true) - - # Flash - if (flash = $(".flash-container")).length > 0 - flash.click -> $(@).fadeOut() - flash.show() - - # Disable form buttons while a form is submitting - $body.on 'ajax:complete, ajax:beforeSend, submit', 'form', (e) -> - buttons = $('[type="submit"]', @) - - switch e.type - when 'ajax:beforeSend', 'submit' - buttons.disable() - else - buttons.enable() - - $(document).ajaxError (e, xhrObj, xhrSetting, xhrErrorText) -> - - if xhrObj.status is 401 - new Flash 'You need to be logged in.', 'alert' - - else if xhrObj.status in [ 404, 500 ] - new Flash 'Something went wrong on our end.', 'alert' - - - # Show/Hide the profile menu when hovering the account box - $('.account-box').hover -> $(@).toggleClass('hover') - - # Commit show suppressed diff - $document.on 'click', '.diff-content .js-show-suppressed-diff', -> - $container = $(@).parent() - $container.next('table').show() - $container.remove() - - $('.navbar-toggle').on 'click', -> - $('.header-content .title').toggle() - $('.header-content .header-logo').toggle() - $('.header-content .navbar-collapse').toggle() - $('.navbar-toggle').toggleClass('active') - - # Show/hide comments on diff - $body.on "click", ".js-toggle-diff-comments", (e) -> - $(@).toggleClass('active') - $(@).closest(".diff-file").find(".notes_holder").toggle() - e.preventDefault() - - $document.off "click", '.js-confirm-danger' - $document.on "click", '.js-confirm-danger', (e) -> - e.preventDefault() - btn = $(e.target) - text = btn.data("confirm-danger-message") - form = btn.closest("form") - new ConfirmDangerModal(form, text) - - - $document.on 'click', 'button', -> - $(this).blur() - - $('input[type="search"]').each -> - $this = $(this) - $this.attr 'value', $this.val() - return - - $document - .off 'keyup', 'input[type="search"]' - .on 'keyup', 'input[type="search"]' , (e) -> - $this = $(this) - $this.attr 'value', $this.val() - - $sidebarGutterToggle = $('.js-sidebar-toggle') - - $document - .off 'breakpoint:change' - .on 'breakpoint:change', (e, breakpoint) -> - if breakpoint is 'sm' or breakpoint is 'xs' - $gutterIcon = $sidebarGutterToggle.find('i') - if $gutterIcon.hasClass('fa-angle-double-right') - $sidebarGutterToggle.trigger('click') - - fitSidebarForSize = -> - oldBootstrapBreakpoint = bootstrapBreakpoint - bootstrapBreakpoint = bp.getBreakpointSize() - if bootstrapBreakpoint != oldBootstrapBreakpoint - $document.trigger('breakpoint:change', [bootstrapBreakpoint]) - - checkInitialSidebarSize = -> - bootstrapBreakpoint = bp.getBreakpointSize() - if bootstrapBreakpoint is "xs" or "sm" - $document.trigger('breakpoint:change', [bootstrapBreakpoint]) - - $window - .off "resize.app" - .on "resize.app", (e) -> - fitSidebarForSize() - - gl.awardsHandler = new AwardsHandler() - checkInitialSidebarSize() - new Aside() - - # Sidenav pinning - if $window.width() < 1024 and $.cookie('pin_nav') is 'true' - $.cookie('pin_nav', 'false', { path: '/', expires: 365 * 10 }) - $('.page-with-sidebar') - .toggleClass('page-sidebar-collapsed page-sidebar-expanded') - .removeClass('page-sidebar-pinned') - $('.navbar-fixed-top').removeClass('header-pinned-nav') - - $document - .off 'click', '.js-nav-pin' - .on 'click', '.js-nav-pin', (e) -> - e.preventDefault() - - $pinBtn = $(e.currentTarget) - $page = $ '.page-with-sidebar' - $topNav = $ '.navbar-fixed-top' - $tooltip = $ "##{$pinBtn.attr('aria-describedby')}" - doPinNav = not $page.is('.page-sidebar-pinned') - tooltipText = 'Pin navigation' - - $(this).toggleClass 'is-active' - - if doPinNav - $page.addClass('page-sidebar-pinned') - $topNav.addClass('header-pinned-nav') - else - $tooltip.remove() # Remove it immediately when collapsing the sidebar - $page.removeClass('page-sidebar-pinned') - .toggleClass('page-sidebar-collapsed page-sidebar-expanded') - $topNav.removeClass('header-pinned-nav') - .toggleClass('header-collapsed header-expanded') - - # Save settings - $.cookie 'pin_nav', doPinNav, { path: '/', expires: 365 * 10 } - - if $.cookie('pin_nav') is 'true' or doPinNav - tooltipText = 'Unpin navigation' - - # Update tooltip text immediately - $tooltip.find('.tooltip-inner').text(tooltipText) - - # Persist tooltip title - $pinBtn.attr('title', tooltipText).tooltip('fixTitle') diff --git a/app/assets/javascripts/aside.js b/app/assets/javascripts/aside.js new file mode 100644 index 00000000000..7b546e79ee0 --- /dev/null +++ b/app/assets/javascripts/aside.js @@ -0,0 +1,26 @@ +(function() { + this.Aside = (function() { + function Aside() { + $(document).off("click", "a.show-aside"); + $(document).on("click", 'a.show-aside', function(e) { + var btn, icon; + e.preventDefault(); + btn = $(e.currentTarget); + icon = btn.find('i'); + if (icon.hasClass('fa-angle-left')) { + btn.parent().find('section').hide(); + btn.parent().find('aside').fadeIn(); + return icon.removeClass('fa-angle-left').addClass('fa-angle-right'); + } else { + btn.parent().find('aside').hide(); + btn.parent().find('section').fadeIn(); + return icon.removeClass('fa-angle-right').addClass('fa-angle-left'); + } + }); + } + + return Aside; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/aside.js.coffee b/app/assets/javascripts/aside.js.coffee deleted file mode 100644 index 66ab5054326..00000000000 --- a/app/assets/javascripts/aside.js.coffee +++ /dev/null @@ -1,16 +0,0 @@ -class @Aside - constructor: -> - $(document).off "click", "a.show-aside" - $(document).on "click", 'a.show-aside', (e) -> - e.preventDefault() - btn = $(e.currentTarget) - icon = btn.find('i') - - if icon.hasClass('fa-angle-left') - btn.parent().find('section').hide() - btn.parent().find('aside').fadeIn() - icon.removeClass('fa-angle-left').addClass('fa-angle-right') - else - btn.parent().find('aside').hide() - btn.parent().find('section').fadeIn() - icon.removeClass('fa-angle-right').addClass('fa-angle-left') diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js new file mode 100644 index 00000000000..7116512d6b7 --- /dev/null +++ b/app/assets/javascripts/autosave.js @@ -0,0 +1,63 @@ +(function() { + this.Autosave = (function() { + function Autosave(field, key) { + this.field = field; + if (key.join != null) { + key = key.join("/"); + } + this.key = "autosave/" + key; + this.field.data("autosave", this); + this.restore(); + this.field.on("input", (function(_this) { + return function() { + return _this.save(); + }; + })(this)); + } + + Autosave.prototype.restore = function() { + var e, error, text; + if (window.localStorage == null) { + return; + } + try { + text = window.localStorage.getItem(this.key); + } catch (error) { + e = error; + return; + } + if ((text != null ? text.length : void 0) > 0) { + this.field.val(text); + } + return this.field.trigger("input"); + }; + + 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 (undefined) {} + } else { + return this.reset(); + } + }; + + Autosave.prototype.reset = function() { + if (window.localStorage == null) { + return; + } + try { + return window.localStorage.removeItem(this.key); + } catch (undefined) {} + }; + + return Autosave; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/autosave.js.coffee b/app/assets/javascripts/autosave.js.coffee deleted file mode 100644 index 28f8e103664..00000000000 --- a/app/assets/javascripts/autosave.js.coffee +++ /dev/null @@ -1,39 +0,0 @@ -class @Autosave - constructor: (field, key) -> - @field = field - - key = key.join("/") if key.join? - @key = "autosave/#{key}" - - @field.data "autosave", this - - @restore() - - @field.on "input", => @save() - - restore: -> - return unless window.localStorage? - - try - text = window.localStorage.getItem @key - catch e - return - - @field.val text if text?.length > 0 - @field.trigger "input" - - save: -> - return unless window.localStorage? - - text = @field.val() - if text?.length > 0 - try - window.localStorage.setItem @key, text - else - @reset() - - reset: -> - return unless window.localStorage? - - try - window.localStorage.removeItem @key diff --git a/app/assets/javascripts/awards_handler.coffee b/app/assets/javascripts/awards_handler.coffee deleted file mode 100644 index 37d0adaa625..00000000000 --- a/app/assets/javascripts/awards_handler.coffee +++ /dev/null @@ -1,372 +0,0 @@ -class @AwardsHandler - - constructor: -> - - @aliases = gl.emojiAliases() - - $(document) - .off 'click', '.js-add-award' - .on 'click', '.js-add-award', (e) => - e.stopPropagation() - e.preventDefault() - - @showEmojiMenu $(e.currentTarget) - - $('html').on 'click', (e) -> - $target = $ e.target - - unless $target.closest('.emoji-menu-content').length - $('.js-awards-block.current').removeClass 'current' - - unless $target.closest('.emoji-menu').length - if $('.emoji-menu').is(':visible') - $('.js-add-award.is-active').removeClass 'is-active' - $('.emoji-menu').removeClass 'is-visible' - - $(document) - .off 'click', '.js-emoji-btn' - .on 'click', '.js-emoji-btn', (e) => - e.preventDefault() - - $target = $ e.currentTarget - emoji = $target.find('.icon').data 'emoji' - - $target.closest('.js-awards-block').addClass 'current' - @addAward @getVotesBlock(), @getAwardUrl(), emoji - - - showEmojiMenu: ($addBtn) -> - - $menu = $ '.emoji-menu' - - if $addBtn.hasClass 'js-note-emoji' - $addBtn.closest('.note').find('.js-awards-block').addClass 'current' - else - $addBtn.closest('.js-awards-block').addClass 'current' - - if $menu.length - $holder = $addBtn.closest('.js-award-holder') - - if $menu.is '.is-visible' - $addBtn.removeClass 'is-active' - $menu.removeClass 'is-visible' - $('#emoji_search').blur() - else - $addBtn.addClass 'is-active' - @positionMenu($menu, $addBtn) - - $menu.addClass 'is-visible' - $('#emoji_search').focus() - else - $addBtn.addClass 'is-loading is-active' - url = @getAwardMenuUrl() - - @createEmojiMenu url, => - $addBtn.removeClass 'is-loading' - $menu = $('.emoji-menu') - @positionMenu($menu, $addBtn) - @renderFrequentlyUsedBlock() unless @frequentEmojiBlockRendered - - setTimeout => - $menu.addClass 'is-visible' - $('#emoji_search').focus() - @setupSearch() - , 200 - - - createEmojiMenu: (awardMenuUrl, callback) -> - - $.get awardMenuUrl, (response) -> - $('body').append response - callback() - - - positionMenu: ($menu, $addBtn) -> - - position = $addBtn.data('position') - - # The menu could potentially be off-screen or in a hidden overflow element - # So we position the element absolute in the body - css = - top: "#{$addBtn.offset().top + $addBtn.outerHeight()}px" - - if position? and position is 'right' - css.left = "#{($addBtn.offset().left - $menu.outerWidth()) + 20}px" - $menu.addClass 'is-aligned-right' - else - css.left = "#{$addBtn.offset().left}px" - $menu.removeClass 'is-aligned-right' - - $menu.css(css) - - - addAward: (votesBlock, awardUrl, emoji, checkMutuality = true, callback) -> - - emoji = @normilizeEmojiName emoji - - @postEmoji awardUrl, emoji, => - @addAwardToEmojiBar votesBlock, emoji, checkMutuality - callback?() - - $('.emoji-menu').removeClass 'is-visible' - - - addAwardToEmojiBar: (votesBlock, emoji, checkForMutuality = true) -> - - @checkMutuality votesBlock, emoji if checkForMutuality - @addEmojiToFrequentlyUsedList emoji - - emoji = @normilizeEmojiName emoji - $emojiButton = @findEmojiIcon(votesBlock, emoji).parent() - - if $emojiButton.length > 0 - if @isActive $emojiButton - @decrementCounter $emojiButton, emoji - else - counter = $emojiButton.find '.js-counter' - counter.text parseInt(counter.text()) + 1 - $emojiButton.addClass 'active' - @addMeToUserList votesBlock, emoji - @animateEmoji $emojiButton - else - votesBlock.removeClass 'hidden' - @createEmoji votesBlock, emoji - - - getVotesBlock: -> - - currentBlock = $ '.js-awards-block.current' - return if currentBlock.length then currentBlock else $('.js-awards-block').eq 0 - - - getAwardUrl: -> return @getVotesBlock().data 'award-url' - - - checkMutuality: (votesBlock, emoji) -> - - awardUrl = @getAwardUrl() - - if emoji in [ 'thumbsup', 'thumbsdown' ] - mutualVote = if emoji is 'thumbsup' then 'thumbsdown' else 'thumbsup' - $emojiButton = votesBlock.find("[data-emoji=#{mutualVote}]").parent() - isAlreadyVoted = $emojiButton.hasClass 'active' - - if isAlreadyVoted - @showEmojiLoader $emojiButton - @addAward votesBlock, awardUrl, mutualVote, false, -> - $emojiButton.removeClass 'is-loading' - - - showEmojiLoader: ($emojiButton) -> - - $loader = $emojiButton.find '.fa-spinner' - - unless $loader.length - $emojiButton.append '<i class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>' - - $emojiButton.addClass 'is-loading' - - - isActive: ($emojiButton) -> $emojiButton.hasClass 'active' - - - decrementCounter: ($emojiButton, emoji) -> - - counter = $ '.js-counter', $emojiButton - counterNumber = parseInt counter.text(), 10 - - if counterNumber > 1 - counter.text counterNumber - 1 - @removeMeFromUserList $emojiButton, emoji - else if emoji is 'thumbsup' or emoji is 'thumbsdown' - $emojiButton.tooltip 'destroy' - counter.text '0' - @removeMeFromUserList $emojiButton, emoji - @removeEmoji $emojiButton if $emojiButton.parents('.note').length - else - @removeEmoji $emojiButton - - $emojiButton.removeClass 'active' - - - removeEmoji: ($emojiButton) -> - - $emojiButton.tooltip('destroy') - $emojiButton.remove() - - $votesBlock = @getVotesBlock() - - if $votesBlock.find('.js-emoji-btn').length is 0 - $votesBlock.addClass 'hidden' - - - getAwardTooltip: ($awardBlock) -> - - return $awardBlock.attr('data-original-title') or $awardBlock.attr('data-title') or '' - - - removeMeFromUserList: ($emojiButton, emoji) -> - - awardBlock = $emojiButton - originalTitle = @getAwardTooltip awardBlock - - authors = originalTitle.split ', ' - authors.splice authors.indexOf('me'), 1 - - newAuthors = authors.join ', ' - - awardBlock - .closest '.js-emoji-btn' - .removeData 'original-title' - .attr 'data-original-title', newAuthors - - @resetTooltip awardBlock - - - addMeToUserList: (votesBlock, emoji) -> - - awardBlock = @findEmojiIcon(votesBlock, emoji).parent() - origTitle = @getAwardTooltip awardBlock - users = [] - - if origTitle - users = origTitle.trim().split ', ' - - users.push 'me' - awardBlock.attr 'title', users.join ', ' - - @resetTooltip awardBlock - - - resetTooltip: (award) -> - - award.tooltip 'destroy' - - # 'destroy' call is asynchronous and there is no appropriate callback on it, this is why we need to set timeout. - cb = -> award.tooltip() - setTimeout cb, 200 - - - createEmoji_: (votesBlock, emoji) -> - - emojiCssClass = @resolveNameToCssClass emoji - buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> - <div class='icon emoji-icon #{emojiCssClass}' data-emoji='#{emoji}'></div> - <span class='award-control-text js-counter'>1</span> - </button>" - - $emojiButton = $ buttonHtml - $emojiButton - .insertBefore votesBlock.find '.js-award-holder' - .find '.emoji-icon' - .data 'emoji', emoji - - @animateEmoji $emojiButton - $('.award-control').tooltip() - votesBlock.removeClass 'current' - - - animateEmoji: ($emoji) -> - - className = 'pulse animated' - - $emoji.addClass className - setTimeout (-> $emoji.removeClass className), 321 - - - createEmoji: (votesBlock, emoji) -> - - if $('.emoji-menu').length - return @createEmoji_ votesBlock, emoji - - @createEmojiMenu @getAwardMenuUrl(), => @createEmoji_ votesBlock, emoji - - - getAwardMenuUrl: -> return gon.award_menu_url - - - resolveNameToCssClass: (emoji) -> - - emojiIcon = $ ".emoji-menu-content [data-emoji='#{emoji}']" - - if emojiIcon.length > 0 - unicodeName = emojiIcon.data 'unicode-name' - else - # Find by alias - unicodeName = $(".emoji-menu-content [data-aliases*=':#{emoji}:']").data 'unicode-name' - - return "emoji-#{unicodeName}" - - - postEmoji: (awardUrl, emoji, callback) -> - - $.post awardUrl, { name: emoji }, (data) -> - callback() if data.ok - - - findEmojiIcon: (votesBlock, emoji) -> - - return votesBlock.find ".js-emoji-btn [data-emoji='#{emoji}']" - - - scrollToAwards: -> - - options = scrollTop: $('.awards').offset().top - 110 - $('body, html').animate options, 200 - - - normilizeEmojiName: (emoji) -> return @aliases[emoji] or emoji - - - addEmojiToFrequentlyUsedList: (emoji) -> - - frequentlyUsedEmojis = @getFrequentlyUsedEmojis() - frequentlyUsedEmojis.push emoji - $.cookie 'frequently_used_emojis', frequentlyUsedEmojis.join(','), { expires: 365 } - - - getFrequentlyUsedEmojis: -> - - frequentlyUsedEmojis = ($.cookie('frequently_used_emojis') or '').split(',') - return _.compact _.uniq frequentlyUsedEmojis - - - renderFrequentlyUsedBlock: -> - - if $.cookie 'frequently_used_emojis' - frequentlyUsedEmojis = @getFrequentlyUsedEmojis() - - ul = $("<ul class='clearfix emoji-menu-list frequent-emojis'>") - - for emoji in frequentlyUsedEmojis - $(".emoji-menu-content [data-emoji='#{emoji}']").closest('li').clone().appendTo(ul) - - $('.emoji-menu-content') - .prepend(ul) - .prepend($('<h5>').text('Frequently used')) - - @frequentEmojiBlockRendered = true - - - setupSearch: -> - - $('input.emoji-search').on 'keyup', (ev) => - term = $(ev.target).val() - - # Clean previous search results - $('ul.emoji-menu-search, h5.emoji-search').remove() - - if term - # Generate a search result block - h5 = $('<h5>').text('Search results') - found_emojis = @searchEmojis(term).show() - ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(found_emojis) - $('.emoji-menu-content ul, .emoji-menu-content h5').hide() - $('.emoji-menu-content').append(h5).append(ul) - else - $('.emoji-menu-content').children().show() - - - searchEmojis: (term) -> - - $(".emoji-menu-list:not(.frequent-emojis) [data-emoji*='#{term}']").closest('li').clone() diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js new file mode 100644 index 00000000000..ea683b31f75 --- /dev/null +++ b/app/assets/javascripts/awards_handler.js @@ -0,0 +1,380 @@ +(function() { + this.AwardsHandler = (function() { + function AwardsHandler() { + this.aliases = gl.emojiAliases(); + $(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) { + return function(e) { + e.stopPropagation(); + e.preventDefault(); + return _this.showEmojiMenu($(e.currentTarget)); + }; + })(this)); + $('html').on('click', function(e) { + var $target; + $target = $(e.target); + if (!$target.closest('.emoji-menu-content').length) { + $('.js-awards-block.current').removeClass('current'); + } + if (!$target.closest('.emoji-menu').length) { + if ($('.emoji-menu').is(':visible')) { + $('.js-add-award.is-active').removeClass('is-active'); + return $('.emoji-menu').removeClass('is-visible'); + } + } + }); + $(document).off('click', '.js-emoji-btn').on('click', '.js-emoji-btn', (function(_this) { + return function(e) { + var $target, emoji; + e.preventDefault(); + $target = $(e.currentTarget); + emoji = $target.find('.icon').data('emoji'); + $target.closest('.js-awards-block').addClass('current'); + return _this.addAward(_this.getVotesBlock(), _this.getAwardUrl(), emoji); + }; + })(this)); + } + + AwardsHandler.prototype.showEmojiMenu = function($addBtn) { + var $holder, $menu, url; + $menu = $('.emoji-menu'); + if ($addBtn.hasClass('js-note-emoji')) { + $addBtn.closest('.note').find('.js-awards-block').addClass('current'); + } else { + $addBtn.closest('.js-awards-block').addClass('current'); + } + if ($menu.length) { + $holder = $addBtn.closest('.js-award-holder'); + if ($menu.is('.is-visible')) { + $addBtn.removeClass('is-active'); + $menu.removeClass('is-visible'); + return $('#emoji_search').blur(); + } else { + $addBtn.addClass('is-active'); + this.positionMenu($menu, $addBtn); + $menu.addClass('is-visible'); + return $('#emoji_search').focus(); + } + } else { + $addBtn.addClass('is-loading is-active'); + url = this.getAwardMenuUrl(); + return this.createEmojiMenu(url, (function(_this) { + return function() { + $addBtn.removeClass('is-loading'); + $menu = $('.emoji-menu'); + _this.positionMenu($menu, $addBtn); + if (!_this.frequentEmojiBlockRendered) { + _this.renderFrequentlyUsedBlock(); + } + return setTimeout(function() { + $menu.addClass('is-visible'); + $('#emoji_search').focus(); + return _this.setupSearch(); + }, 200); + }; + })(this)); + } + }; + + AwardsHandler.prototype.createEmojiMenu = function(awardMenuUrl, callback) { + return $.get(awardMenuUrl, function(response) { + $('body').append(response); + return callback(); + }); + }; + + AwardsHandler.prototype.positionMenu = function($menu, $addBtn) { + var css, position; + position = $addBtn.data('position'); + css = { + top: ($addBtn.offset().top + $addBtn.outerHeight()) + "px" + }; + if ((position != null) && position === 'right') { + css.left = (($addBtn.offset().left - $menu.outerWidth()) + 20) + "px"; + $menu.addClass('is-aligned-right'); + } else { + css.left = ($addBtn.offset().left) + "px"; + $menu.removeClass('is-aligned-right'); + } + return $menu.css(css); + }; + + AwardsHandler.prototype.addAward = function(votesBlock, awardUrl, emoji, checkMutuality, callback) { + if (checkMutuality == null) { + checkMutuality = true; + } + emoji = this.normilizeEmojiName(emoji); + this.postEmoji(awardUrl, emoji, (function(_this) { + return function() { + _this.addAwardToEmojiBar(votesBlock, emoji, checkMutuality); + return typeof callback === "function" ? callback() : void 0; + }; + })(this)); + return $('.emoji-menu').removeClass('is-visible'); + }; + + AwardsHandler.prototype.addAwardToEmojiBar = function(votesBlock, emoji, checkForMutuality) { + var $emojiButton, counter; + if (checkForMutuality == null) { + checkForMutuality = true; + } + if (checkForMutuality) { + this.checkMutuality(votesBlock, emoji); + } + this.addEmojiToFrequentlyUsedList(emoji); + emoji = this.normilizeEmojiName(emoji); + $emojiButton = this.findEmojiIcon(votesBlock, emoji).parent(); + if ($emojiButton.length > 0) { + if (this.isActive($emojiButton)) { + return this.decrementCounter($emojiButton, emoji); + } else { + counter = $emojiButton.find('.js-counter'); + counter.text(parseInt(counter.text()) + 1); + $emojiButton.addClass('active'); + this.addMeToUserList(votesBlock, emoji); + return this.animateEmoji($emojiButton); + } + } else { + votesBlock.removeClass('hidden'); + return this.createEmoji(votesBlock, emoji); + } + }; + + AwardsHandler.prototype.getVotesBlock = function() { + var currentBlock; + currentBlock = $('.js-awards-block.current'); + if (currentBlock.length) { + return currentBlock; + } else { + return $('.js-awards-block').eq(0); + } + }; + + AwardsHandler.prototype.getAwardUrl = function() { + return this.getVotesBlock().data('award-url'); + }; + + AwardsHandler.prototype.checkMutuality = function(votesBlock, emoji) { + var $emojiButton, awardUrl, isAlreadyVoted, mutualVote; + awardUrl = this.getAwardUrl(); + if (emoji === 'thumbsup' || emoji === 'thumbsdown') { + mutualVote = emoji === 'thumbsup' ? 'thumbsdown' : 'thumbsup'; + $emojiButton = votesBlock.find("[data-emoji=" + mutualVote + "]").parent(); + isAlreadyVoted = $emojiButton.hasClass('active'); + if (isAlreadyVoted) { + this.showEmojiLoader($emojiButton); + return this.addAward(votesBlock, awardUrl, mutualVote, false, function() { + return $emojiButton.removeClass('is-loading'); + }); + } + } + }; + + AwardsHandler.prototype.showEmojiLoader = function($emojiButton) { + var $loader; + $loader = $emojiButton.find('.fa-spinner'); + if (!$loader.length) { + $emojiButton.append('<i class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>'); + } + return $emojiButton.addClass('is-loading'); + }; + + AwardsHandler.prototype.isActive = function($emojiButton) { + return $emojiButton.hasClass('active'); + }; + + AwardsHandler.prototype.decrementCounter = function($emojiButton, emoji) { + var counter, counterNumber; + counter = $('.js-counter', $emojiButton); + counterNumber = parseInt(counter.text(), 10); + if (counterNumber > 1) { + counter.text(counterNumber - 1); + this.removeMeFromUserList($emojiButton, emoji); + } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') { + $emojiButton.tooltip('destroy'); + counter.text('0'); + this.removeMeFromUserList($emojiButton, emoji); + if ($emojiButton.parents('.note').length) { + this.removeEmoji($emojiButton); + } + } else { + this.removeEmoji($emojiButton); + } + return $emojiButton.removeClass('active'); + }; + + AwardsHandler.prototype.removeEmoji = function($emojiButton) { + var $votesBlock; + $emojiButton.tooltip('destroy'); + $emojiButton.remove(); + $votesBlock = this.getVotesBlock(); + if ($votesBlock.find('.js-emoji-btn').length === 0) { + return $votesBlock.addClass('hidden'); + } + }; + + AwardsHandler.prototype.getAwardTooltip = function($awardBlock) { + return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || ''; + }; + + AwardsHandler.prototype.removeMeFromUserList = function($emojiButton, emoji) { + var authors, awardBlock, newAuthors, originalTitle; + awardBlock = $emojiButton; + originalTitle = this.getAwardTooltip(awardBlock); + authors = originalTitle.split(', '); + authors.splice(authors.indexOf('me'), 1); + newAuthors = authors.join(', '); + awardBlock.closest('.js-emoji-btn').removeData('original-title').attr('data-original-title', newAuthors); + return this.resetTooltip(awardBlock); + }; + + AwardsHandler.prototype.addMeToUserList = function(votesBlock, emoji) { + var awardBlock, origTitle, users; + awardBlock = this.findEmojiIcon(votesBlock, emoji).parent(); + origTitle = this.getAwardTooltip(awardBlock); + users = []; + if (origTitle) { + users = origTitle.trim().split(', '); + } + users.push('me'); + awardBlock.attr('title', users.join(', ')); + return this.resetTooltip(awardBlock); + }; + + AwardsHandler.prototype.resetTooltip = function(award) { + var cb; + award.tooltip('destroy'); + cb = function() { + return award.tooltip(); + }; + return setTimeout(cb, 200); + }; + + AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) { + var $emojiButton, buttonHtml, emojiCssClass; + emojiCssClass = this.resolveNameToCssClass(emoji); + buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>"; + $emojiButton = $(buttonHtml); + $emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji); + this.animateEmoji($emojiButton); + $('.award-control').tooltip(); + return votesBlock.removeClass('current'); + }; + + AwardsHandler.prototype.animateEmoji = function($emoji) { + var className; + className = 'pulse animated'; + $emoji.addClass(className); + return setTimeout((function() { + return $emoji.removeClass(className); + }), 321); + }; + + AwardsHandler.prototype.createEmoji = function(votesBlock, emoji) { + if ($('.emoji-menu').length) { + return this.createEmoji_(votesBlock, emoji); + } + return this.createEmojiMenu(this.getAwardMenuUrl(), (function(_this) { + return function() { + return _this.createEmoji_(votesBlock, emoji); + }; + })(this)); + }; + + AwardsHandler.prototype.getAwardMenuUrl = function() { + return gon.award_menu_url; + }; + + AwardsHandler.prototype.resolveNameToCssClass = function(emoji) { + var emojiIcon, unicodeName; + emojiIcon = $(".emoji-menu-content [data-emoji='" + emoji + "']"); + if (emojiIcon.length > 0) { + unicodeName = emojiIcon.data('unicode-name'); + } else { + unicodeName = $(".emoji-menu-content [data-aliases*=':" + emoji + ":']").data('unicode-name'); + } + return "emoji-" + unicodeName; + }; + + AwardsHandler.prototype.postEmoji = function(awardUrl, emoji, callback) { + return $.post(awardUrl, { + name: emoji + }, function(data) { + if (data.ok) { + return callback(); + } + }); + }; + + AwardsHandler.prototype.findEmojiIcon = function(votesBlock, emoji) { + return votesBlock.find(".js-emoji-btn [data-emoji='" + emoji + "']"); + }; + + AwardsHandler.prototype.scrollToAwards = function() { + var options; + options = { + scrollTop: $('.awards').offset().top - 110 + }; + return $('body, html').animate(options, 200); + }; + + AwardsHandler.prototype.normilizeEmojiName = function(emoji) { + return this.aliases[emoji] || emoji; + }; + + AwardsHandler.prototype.addEmojiToFrequentlyUsedList = function(emoji) { + var frequentlyUsedEmojis; + frequentlyUsedEmojis = this.getFrequentlyUsedEmojis(); + frequentlyUsedEmojis.push(emoji); + return $.cookie('frequently_used_emojis', frequentlyUsedEmojis.join(','), { + expires: 365 + }); + }; + + AwardsHandler.prototype.getFrequentlyUsedEmojis = function() { + var frequentlyUsedEmojis; + frequentlyUsedEmojis = ($.cookie('frequently_used_emojis') || '').split(','); + return _.compact(_.uniq(frequentlyUsedEmojis)); + }; + + AwardsHandler.prototype.renderFrequentlyUsedBlock = function() { + var emoji, frequentlyUsedEmojis, i, len, ul; + if ($.cookie('frequently_used_emojis')) { + frequentlyUsedEmojis = this.getFrequentlyUsedEmojis(); + ul = $("<ul class='clearfix emoji-menu-list frequent-emojis'>"); + for (i = 0, len = frequentlyUsedEmojis.length; i < len; i++) { + emoji = frequentlyUsedEmojis[i]; + $(".emoji-menu-content [data-emoji='" + emoji + "']").closest('li').clone().appendTo(ul); + } + $('.emoji-menu-content').prepend(ul).prepend($('<h5>').text('Frequently used')); + } + return this.frequentEmojiBlockRendered = true; + }; + + AwardsHandler.prototype.setupSearch = function() { + return $('input.emoji-search').on('keyup', (function(_this) { + return function(ev) { + var found_emojis, h5, term, ul; + term = $(ev.target).val(); + $('ul.emoji-menu-search, h5.emoji-search').remove(); + if (term) { + h5 = $('<h5>').text('Search results'); + found_emojis = _this.searchEmojis(term).show(); + ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(found_emojis); + $('.emoji-menu-content ul, .emoji-menu-content h5').hide(); + return $('.emoji-menu-content').append(h5).append(ul); + } else { + return $('.emoji-menu-content').children().show(); + } + }; + })(this)); + }; + + AwardsHandler.prototype.searchEmojis = function(term) { + return $(".emoji-menu-list:not(.frequent-emojis) [data-emoji*='" + term + "']").closest('li').clone(); + }; + + return AwardsHandler; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js new file mode 100644 index 00000000000..f977a1e8a7b --- /dev/null +++ b/app/assets/javascripts/behaviors/autosize.js @@ -0,0 +1,30 @@ + +/*= require jquery.ba-resize */ + + +/*= require autosize */ + +(function() { + $(function() { + var $fields; + $fields = $('.js-autosize'); + $fields.on('autosize:resized', function() { + var $field; + $field = $(this); + return $field.data('height', $field.outerHeight()); + }); + $fields.on('resize.autosize', function() { + var $field; + $field = $(this); + if ($field.data('height') !== $field.outerHeight()) { + $field.data('height', $field.outerHeight()); + autosize.destroy($field); + return $field.css('max-height', window.outerHeight); + } + }); + autosize($fields); + autosize.update($fields); + return $fields.css('resize', 'vertical'); + }); + +}).call(this); diff --git a/app/assets/javascripts/behaviors/autosize.js.coffee b/app/assets/javascripts/behaviors/autosize.js.coffee deleted file mode 100644 index a072fe48a98..00000000000 --- a/app/assets/javascripts/behaviors/autosize.js.coffee +++ /dev/null @@ -1,22 +0,0 @@ -#= require jquery.ba-resize -#= require autosize - -$ -> - $fields = $('.js-autosize') - - $fields.on 'autosize:resized', -> - $field = $(@) - $field.data('height', $field.outerHeight()) - - $fields.on 'resize.autosize', -> - $field = $(@) - - if $field.data('height') != $field.outerHeight() - $field.data('height', $field.outerHeight()) - autosize.destroy($field) - $field.css('max-height', window.outerHeight) - - autosize($fields) - autosize.update($fields) - - $fields.css('resize', 'vertical') diff --git a/app/assets/javascripts/behaviors/details_behavior.coffee b/app/assets/javascripts/behaviors/details_behavior.coffee deleted file mode 100644 index decab3e1bed..00000000000 --- a/app/assets/javascripts/behaviors/details_behavior.coffee +++ /dev/null @@ -1,15 +0,0 @@ -$ -> - $("body").on "click", ".js-details-target", -> - container = $(@).closest(".js-details-container") - container.toggleClass("open") - - # Show details content. Hides link after click. - # - # %div - # %a.js-details-expand - # %div.js-details-content - # - $("body").on "click", ".js-details-expand", (e) -> - $(@).next('.js-details-content').removeClass("hide") - $(@).hide() - e.preventDefault() diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js new file mode 100644 index 00000000000..3631d1b74ac --- /dev/null +++ b/app/assets/javascripts/behaviors/details_behavior.js @@ -0,0 +1,15 @@ +(function() { + $(function() { + $("body").on("click", ".js-details-target", function() { + var container; + container = $(this).closest(".js-details-container"); + return container.toggleClass("open"); + }); + return $("body").on("click", ".js-details-expand", function(e) { + $(this).next('.js-details-content').removeClass("hide"); + $(this).hide(); + return e.preventDefault(); + }); + }); + +}).call(this); diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js new file mode 100644 index 00000000000..3527d0a95fc --- /dev/null +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -0,0 +1,58 @@ + +/*= require extensions/jquery */ + +(function() { + var isMac, keyCodeIs; + + isMac = function() { + return navigator.userAgent.match(/Macintosh/); + }; + + keyCodeIs = function(e, keyCode) { + if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) { + return false; + } + return e.keyCode === keyCode; + }; + + $(document).on('keydown.quick_submit', '.js-quick-submit', function(e) { + var $form, $submit_button; + if (!keyCodeIs(e, 13)) { + return; + } + if (!((e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey) || (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey))) { + return; + } + e.preventDefault(); + $form = $(e.target).closest('form'); + $submit_button = $form.find('input[type=submit], button[type=submit]'); + if ($submit_button.attr('disabled')) { + return; + } + $submit_button.disable(); + return $form.submit(); + }); + + $(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function(e) { + var $this, title; + if (!keyCodeIs(e, 9)) { + return; + } + if (isMac()) { + title = "You can also press ⌘-Enter"; + } else { + title = "You can also press Ctrl-Enter"; + } + $this = $(this); + return $this.tooltip({ + container: 'body', + html: 'true', + placement: 'auto top', + title: title, + trigger: 'manual' + }).tooltip('show').one('blur', function() { + return $this.tooltip('hide'); + }); + }); + +}).call(this); diff --git a/app/assets/javascripts/behaviors/quick_submit.js.coffee b/app/assets/javascripts/behaviors/quick_submit.js.coffee deleted file mode 100644 index 3cb96bacaa7..00000000000 --- a/app/assets/javascripts/behaviors/quick_submit.js.coffee +++ /dev/null @@ -1,56 +0,0 @@ -# Quick Submit behavior -# -# When a child field of a form with a `js-quick-submit` class receives a -# "Meta+Enter" (Mac) or "Ctrl+Enter" (Linux/Windows) key combination, the form -# is submitted. -# -#= require extensions/jquery -# -# ### Example Markup -# -# <form action="/foo" class="js-quick-submit"> -# <input type="text" /> -# <textarea></textarea> -# <input type="submit" value="Submit" /> -# </form> -# -isMac = -> - navigator.userAgent.match(/Macintosh/) - -keyCodeIs = (e, keyCode) -> - return false if (e.originalEvent && e.originalEvent.repeat) || e.repeat - return e.keyCode == keyCode - -$(document).on 'keydown.quick_submit', '.js-quick-submit', (e) -> - return unless keyCodeIs(e, 13) # Enter - - return unless (e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey) || (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) - - e.preventDefault() - - $form = $(e.target).closest('form') - $submit_button = $form.find('input[type=submit], button[type=submit]') - - return if $submit_button.attr('disabled') - - $submit_button.disable() - $form.submit() - -# If the user tabs to a submit button on a `js-quick-submit` form, display a -# tooltip to let them know they could've used the hotkey -$(document).on 'keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', (e) -> - return unless keyCodeIs(e, 9) # Tab - - if isMac() - title = "You can also press ⌘-Enter" - else - title = "You can also press Ctrl-Enter" - - $this = $(@) - $this.tooltip( - container: 'body' - html: 'true' - placement: 'auto top' - title: title - trigger: 'manual' - ).tooltip('show').one('blur', -> $this.tooltip('hide')) diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js new file mode 100644 index 00000000000..db0b36b24e9 --- /dev/null +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -0,0 +1,45 @@ + +/*= require extensions/jquery */ + +(function() { + $.fn.requiresInput = function() { + var $button, $form, fieldSelector, requireInput, required; + $form = $(this); + $button = $('button[type=submit], input[type=submit]', $form); + required = '[required=required]'; + fieldSelector = "input" + required + ", select" + required + ", textarea" + required; + requireInput = function() { + var values; + values = _.map($(fieldSelector, $form), function(field) { + return field.value; + }); + if (values.length && _.any(values, _.isEmpty)) { + return $button.disable(); + } else { + return $button.enable(); + } + }; + requireInput(); + return $form.on('change input', fieldSelector, requireInput); + }; + + $(function() { + var $form, hideOrShowHelpBlock; + $form = $('form.js-requires-input'); + $form.requiresInput(); + hideOrShowHelpBlock = function(form) { + var selected; + selected = $('.js-select-namespace option:selected'); + if (selected.length && selected.data('options-parent') === 'groups') { + return form.find('.help-block').hide(); + } else if (selected.length) { + return form.find('.help-block').show(); + } + }; + hideOrShowHelpBlock($form); + return $('.select2.js-select-namespace').change(function() { + return hideOrShowHelpBlock($form); + }); + }); + +}).call(this); diff --git a/app/assets/javascripts/behaviors/requires_input.js.coffee b/app/assets/javascripts/behaviors/requires_input.js.coffee deleted file mode 100644 index 0faa570ce13..00000000000 --- a/app/assets/javascripts/behaviors/requires_input.js.coffee +++ /dev/null @@ -1,52 +0,0 @@ -# Requires Input behavior -# -# When called on a form with input fields with the `required` attribute, the -# form's submit button will be disabled until all required fields have values. -# -#= require extensions/jquery -# -# ### Example Markup -# -# <form class="js-requires-input"> -# <input type="text" required="required"> -# <input type="submit" value="Submit"> -# </form> -# -$.fn.requiresInput = -> - $form = $(this) - $button = $('button[type=submit], input[type=submit]', $form) - - required = '[required=required]' - fieldSelector = "input#{required}, select#{required}, textarea#{required}" - - requireInput = -> - # Collect the input values of *all* required fields - values = _.map $(fieldSelector, $form), (field) -> field.value - - # Disable the button if any required fields are empty - if values.length && _.any(values, _.isEmpty) - $button.disable() - else - $button.enable() - - # Set initial button state - requireInput() - - $form.on 'change input', fieldSelector, requireInput - -$ -> - $form = $('form.js-requires-input') - $form.requiresInput() - - # Hide or Show the help block when creating a new project - # based on the option selected - hideOrShowHelpBlock = (form) -> - selected = $('.js-select-namespace option:selected') - if selected.length and selected.data('options-parent') is 'groups' - return form.find('.help-block').hide() - else if selected.length - form.find('.help-block').show() - - hideOrShowHelpBlock($form) - - $('.select2.js-select-namespace').change -> hideOrShowHelpBlock($form) diff --git a/app/assets/javascripts/behaviors/toggler_behavior.coffee b/app/assets/javascripts/behaviors/toggler_behavior.coffee deleted file mode 100644 index 177b6918270..00000000000 --- a/app/assets/javascripts/behaviors/toggler_behavior.coffee +++ /dev/null @@ -1,14 +0,0 @@ -$ -> - # Toggle button. Show/hide content inside parent container. - # Button does not change visibility. If button has icon - it changes chevron style. - # - # %div.js-toggle-container - # %a.js-toggle-button - # %div.js-toggle-content - # - $("body").on "click", ".js-toggle-button", (e) -> - $(@).find('i'). - toggleClass('fa fa-chevron-down'). - toggleClass('fa fa-chevron-up') - $(@).closest(".js-toggle-container").find(".js-toggle-content").toggle() - e.preventDefault() diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js new file mode 100644 index 00000000000..1b7b63489ea --- /dev/null +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -0,0 +1,10 @@ +(function() { + $(function() { + return $("body").on("click", ".js-toggle-button", function(e) { + $(this).find('i').toggleClass('fa fa-chevron-down').toggleClass('fa fa-chevron-up'); + $(this).closest(".js-toggle-container").find(".js-toggle-content").toggle(); + return e.preventDefault(); + }); + }); + +}).call(this); diff --git a/app/assets/javascripts/blob/blob_ci_yaml.js b/app/assets/javascripts/blob/blob_ci_yaml.js new file mode 100644 index 00000000000..68758574967 --- /dev/null +++ b/app/assets/javascripts/blob/blob_ci_yaml.js @@ -0,0 +1,46 @@ + +/*= require blob/template_selector */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.BlobCiYamlSelector = (function(superClass) { + extend(BlobCiYamlSelector, superClass); + + function BlobCiYamlSelector() { + return BlobCiYamlSelector.__super__.constructor.apply(this, arguments); + } + + BlobCiYamlSelector.prototype.requestFile = function(query) { + return Api.gitlabCiYml(query.name, this.requestFileSuccess.bind(this)); + }; + + return BlobCiYamlSelector; + + })(TemplateSelector); + + this.BlobCiYamlSelectors = (function() { + function BlobCiYamlSelectors(opts) { + var ref; + this.$dropdowns = (ref = opts.$dropdowns) != null ? ref : $('.js-gitlab-ci-yml-selector'), this.editor = opts.editor; + this.$dropdowns.each((function(_this) { + return function(i, dropdown) { + var $dropdown; + $dropdown = $(dropdown); + return new BlobCiYamlSelector({ + pattern: /(.gitlab-ci.yml)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-gitlab-ci-yml-selector-wrap'), + dropdown: $dropdown, + editor: _this.editor + }); + }; + })(this)); + } + + return BlobCiYamlSelectors; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/blob/blob_ci_yaml.js.coffee b/app/assets/javascripts/blob/blob_ci_yaml.js.coffee deleted file mode 100644 index d9a03d05529..00000000000 --- a/app/assets/javascripts/blob/blob_ci_yaml.js.coffee +++ /dev/null @@ -1,23 +0,0 @@ -#= require blob/template_selector - -class @BlobCiYamlSelector extends TemplateSelector - requestFile: (query) -> - Api.gitlabCiYml query.name, @requestFileSuccess.bind(@) - -class @BlobCiYamlSelectors - constructor: (opts) -> - { - @$dropdowns = $('.js-gitlab-ci-yml-selector') - @editor - } = opts - - @$dropdowns.each (i, dropdown) => - $dropdown = $(dropdown) - - new BlobCiYamlSelector( - pattern: /(.gitlab-ci.yml)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-gitlab-ci-yml-selector-wrap'), - dropdown: $dropdown, - editor: @editor - ) diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js new file mode 100644 index 00000000000..f4044f22db2 --- /dev/null +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -0,0 +1,62 @@ +(function() { + this.BlobFileDropzone = (function() { + function BlobFileDropzone(form, method) { + var dropzone, form_dropzone, submitButton; + form_dropzone = form.find('.dropzone'); + Dropzone.autoDiscover = false; + dropzone = form_dropzone.dropzone({ + autoDiscover: false, + autoProcessQueue: false, + url: form.attr('action'), + method: method, + clickable: true, + uploadMultiple: false, + paramName: "file", + maxFilesize: gon.max_file_size || 10, + parallelUploads: 1, + maxFiles: 1, + addRemoveLinks: true, + previewsContainer: '.dropzone-previews', + headers: { + "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + }, + init: function() { + this.on('addedfile', function(file) { + $('.dropzone-alerts').html('').hide(); + }); + this.on('success', function(header, response) { + window.location.href = response.filePath; + }); + this.on('maxfilesexceeded', function(file) { + this.removeFile(file); + }); + return this.on('sending', function(file, xhr, formData) { + formData.append('target_branch', form.find('.js-target-branch').val()); + formData.append('create_merge_request', form.find('.js-create-merge-request').val()); + formData.append('commit_message', form.find('.js-commit-message').val()); + }); + }, + error: function(file, errorMessage) { + var stripped; + stripped = $("<div/>").html(errorMessage).text(); + $('.dropzone-alerts').html('Error uploading file: \"' + stripped + '\"').show(); + this.removeFile(file); + } + }); + submitButton = form.find('#submit-all')[0]; + submitButton.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (dropzone[0].dropzone.getQueuedFiles().length === 0) { + alert("Please select a file"); + } + dropzone[0].dropzone.processQueue(); + return false; + }); + } + + return BlobFileDropzone; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js.coffee b/app/assets/javascripts/blob/blob_file_dropzone.js.coffee deleted file mode 100644 index 9df932817f6..00000000000 --- a/app/assets/javascripts/blob/blob_file_dropzone.js.coffee +++ /dev/null @@ -1,57 +0,0 @@ -class @BlobFileDropzone - constructor: (form, method) -> - form_dropzone = form.find('.dropzone') - Dropzone.autoDiscover = false - dropzone = form_dropzone.dropzone( - autoDiscover: false - autoProcessQueue: false - url: form.attr('action') - # Rails uses a hidden input field for PUT - # http://stackoverflow.com/questions/21056482/how-to-set-method-put-in-form-tag-in-rails - method: method - clickable: true - uploadMultiple: false - paramName: "file" - maxFilesize: gon.max_file_size or 10 - parallelUploads: 1 - maxFiles: 1 - addRemoveLinks: true - previewsContainer: '.dropzone-previews' - headers: - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") - - init: -> - this.on 'addedfile', (file) -> - $('.dropzone-alerts').html('').hide() - - return - - this.on 'success', (header, response) -> - window.location.href = response.filePath - return - - this.on 'maxfilesexceeded', (file) -> - @removeFile file - return - - this.on 'sending', (file, xhr, formData) -> - formData.append('target_branch', form.find('.js-target-branch').val()) - formData.append('create_merge_request', form.find('.js-create-merge-request').val()) - formData.append('commit_message', form.find('.js-commit-message').val()) - return - - # Override behavior of adding error underneath preview - error: (file, errorMessage) -> - stripped = $("<div/>").html(errorMessage).text(); - $('.dropzone-alerts').html('Error uploading file: \"' + stripped + '\"').show() - @removeFile file - return - ) - - submitButton = form.find('#submit-all')[0] - submitButton.addEventListener 'click', (e) -> - e.preventDefault() - e.stopPropagation() - alert "Please select a file" if dropzone[0].dropzone.getQueuedFiles().length == 0 - dropzone[0].dropzone.processQueue() - return false diff --git a/app/assets/javascripts/blob/blob_gitignore_selector.js b/app/assets/javascripts/blob/blob_gitignore_selector.js new file mode 100644 index 00000000000..54a09e919f8 --- /dev/null +++ b/app/assets/javascripts/blob/blob_gitignore_selector.js @@ -0,0 +1,23 @@ + +/*= require blob/template_selector */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.BlobGitignoreSelector = (function(superClass) { + extend(BlobGitignoreSelector, superClass); + + function BlobGitignoreSelector() { + return BlobGitignoreSelector.__super__.constructor.apply(this, arguments); + } + + BlobGitignoreSelector.prototype.requestFile = function(query) { + return Api.gitignoreText(query.name, this.requestFileSuccess.bind(this)); + }; + + return BlobGitignoreSelector; + + })(TemplateSelector); + +}).call(this); diff --git a/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee b/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee deleted file mode 100644 index 8d0e3f363d1..00000000000 --- a/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee +++ /dev/null @@ -1,5 +0,0 @@ -#= require blob/template_selector - -class @BlobGitignoreSelector extends TemplateSelector - requestFile: (query) -> - Api.gitignoreText query.name, @requestFileSuccess.bind(@) diff --git a/app/assets/javascripts/blob/blob_gitignore_selectors.js b/app/assets/javascripts/blob/blob_gitignore_selectors.js new file mode 100644 index 00000000000..4e9500428b2 --- /dev/null +++ b/app/assets/javascripts/blob/blob_gitignore_selectors.js @@ -0,0 +1,25 @@ +(function() { + this.BlobGitignoreSelectors = (function() { + function BlobGitignoreSelectors(opts) { + var ref; + this.$dropdowns = (ref = opts.$dropdowns) != null ? ref : $('.js-gitignore-selector'), this.editor = opts.editor; + this.$dropdowns.each((function(_this) { + return function(i, dropdown) { + var $dropdown; + $dropdown = $(dropdown); + return new BlobGitignoreSelector({ + pattern: /(.gitignore)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-gitignore-selector-wrap'), + dropdown: $dropdown, + editor: _this.editor + }); + }; + })(this)); + } + + return BlobGitignoreSelectors; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee b/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee deleted file mode 100644 index a719ba25122..00000000000 --- a/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee +++ /dev/null @@ -1,17 +0,0 @@ -class @BlobGitignoreSelectors - constructor: (opts) -> - { - @$dropdowns = $('.js-gitignore-selector') - @editor - } = opts - - @$dropdowns.each (i, dropdown) => - $dropdown = $(dropdown) - - new BlobGitignoreSelector( - pattern: /(.gitignore)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-gitignore-selector-wrap'), - dropdown: $dropdown, - editor: @editor - ) diff --git a/app/assets/javascripts/blob/blob_license_selector.js b/app/assets/javascripts/blob/blob_license_selector.js new file mode 100644 index 00000000000..9a8ef08f4e5 --- /dev/null +++ b/app/assets/javascripts/blob/blob_license_selector.js @@ -0,0 +1,28 @@ + +/*= require blob/template_selector */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.BlobLicenseSelector = (function(superClass) { + extend(BlobLicenseSelector, superClass); + + function BlobLicenseSelector() { + return BlobLicenseSelector.__super__.constructor.apply(this, arguments); + } + + BlobLicenseSelector.prototype.requestFile = function(query) { + var data; + data = { + project: this.dropdown.data('project'), + fullname: this.dropdown.data('fullname') + }; + return Api.licenseText(query.id, data, this.requestFileSuccess.bind(this)); + }; + + return BlobLicenseSelector; + + })(TemplateSelector); + +}).call(this); diff --git a/app/assets/javascripts/blob/blob_license_selector.js.coffee b/app/assets/javascripts/blob/blob_license_selector.js.coffee deleted file mode 100644 index a3cc8dd844c..00000000000 --- a/app/assets/javascripts/blob/blob_license_selector.js.coffee +++ /dev/null @@ -1,9 +0,0 @@ -#= require blob/template_selector - -class @BlobLicenseSelector extends TemplateSelector - requestFile: (query) -> - data = - project: @dropdown.data('project') - fullname: @dropdown.data('fullname') - - Api.licenseText query.id, data, @requestFileSuccess.bind(@) diff --git a/app/assets/javascripts/blob/blob_license_selectors.js b/app/assets/javascripts/blob/blob_license_selectors.js new file mode 100644 index 00000000000..39237705e8d --- /dev/null +++ b/app/assets/javascripts/blob/blob_license_selectors.js @@ -0,0 +1,25 @@ +(function() { + this.BlobLicenseSelectors = (function() { + function BlobLicenseSelectors(opts) { + var ref; + this.$dropdowns = (ref = opts.$dropdowns) != null ? ref : $('.js-license-selector'), this.editor = opts.editor; + this.$dropdowns.each((function(_this) { + return function(i, dropdown) { + var $dropdown; + $dropdown = $(dropdown); + return new BlobLicenseSelector({ + pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-license-selector-wrap'), + dropdown: $dropdown, + editor: _this.editor + }); + }; + })(this)); + } + + return BlobLicenseSelectors; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/blob/blob_license_selectors.js.coffee b/app/assets/javascripts/blob/blob_license_selectors.js.coffee deleted file mode 100644 index 68438733108..00000000000 --- a/app/assets/javascripts/blob/blob_license_selectors.js.coffee +++ /dev/null @@ -1,17 +0,0 @@ -class @BlobLicenseSelectors - constructor: (opts) -> - { - @$dropdowns = $('.js-license-selector') - @editor - } = opts - - @$dropdowns.each (i, dropdown) => - $dropdown = $(dropdown) - - new BlobLicenseSelector( - pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-license-selector-wrap'), - dropdown: $dropdown, - editor: @editor - ) diff --git a/app/assets/javascripts/blob/edit_blob.js b/app/assets/javascripts/blob/edit_blob.js new file mode 100644 index 00000000000..649c79daee8 --- /dev/null +++ b/app/assets/javascripts/blob/edit_blob.js @@ -0,0 +1,66 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.EditBlob = (function() { + function EditBlob(assets_path, ace_mode) { + if (ace_mode == null) { + ace_mode = null; + } + this.editModeLinkClickHandler = bind(this.editModeLinkClickHandler, this); + ace.config.set("modePath", assets_path + "/ace"); + ace.config.loadModule("ace/ext/searchbox"); + this.editor = ace.edit("editor"); + this.editor.focus(); + if (ace_mode) { + this.editor.getSession().setMode("ace/mode/" + ace_mode); + } + $('form').submit((function(_this) { + return function() { + return $("#file-content").val(_this.editor.getValue()); + }; + })(this)); + this.initModePanesAndLinks(); + new BlobLicenseSelectors({ + editor: this.editor + }); + new BlobGitignoreSelectors({ + editor: this.editor + }); + new BlobCiYamlSelectors({ + editor: this.editor + }); + } + + EditBlob.prototype.initModePanesAndLinks = function() { + this.$editModePanes = $(".js-edit-mode-pane"); + this.$editModeLinks = $(".js-edit-mode a"); + return this.$editModeLinks.click(this.editModeLinkClickHandler); + }; + + EditBlob.prototype.editModeLinkClickHandler = function(event) { + var currentLink, currentPane, paneId; + event.preventDefault(); + currentLink = $(event.target); + paneId = currentLink.attr("href"); + currentPane = this.$editModePanes.filter(paneId); + this.$editModeLinks.parent().removeClass("active hover"); + currentLink.parent().addClass("active hover"); + this.$editModePanes.hide(); + currentPane.fadeIn(200); + if (paneId === "#preview") { + return $.post(currentLink.data("preview-url"), { + content: this.editor.getValue() + }, function(response) { + currentPane.empty().append(response); + return currentPane.syntaxHighlight(); + }); + } else { + return this.editor.focus(); + } + }; + + return EditBlob; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/blob/edit_blob.js.coffee b/app/assets/javascripts/blob/edit_blob.js.coffee deleted file mode 100644 index 19e584519d7..00000000000 --- a/app/assets/javascripts/blob/edit_blob.js.coffee +++ /dev/null @@ -1,42 +0,0 @@ -class @EditBlob - constructor: (assets_path, ace_mode = null) -> - ace.config.set "modePath", "#{assets_path}/ace" - ace.config.loadModule "ace/ext/searchbox" - @editor = ace.edit("editor") - @editor.focus() - @editor.getSession().setMode "ace/mode/#{ace_mode}" if ace_mode - - # Before a form submission, move the content from the Ace editor into the - # submitted textarea - $('form').submit => - $("#file-content").val(@editor.getValue()) - - @initModePanesAndLinks() - - new BlobLicenseSelectors { @editor } - new BlobGitignoreSelectors { @editor } - new BlobCiYamlSelectors { @editor } - - initModePanesAndLinks: -> - @$editModePanes = $(".js-edit-mode-pane") - @$editModeLinks = $(".js-edit-mode a") - @$editModeLinks.click @editModeLinkClickHandler - - editModeLinkClickHandler: (event) => - event.preventDefault() - currentLink = $(event.target) - paneId = currentLink.attr("href") - currentPane = @$editModePanes.filter(paneId) - @$editModeLinks.parent().removeClass "active hover" - currentLink.parent().addClass "active hover" - @$editModePanes.hide() - currentPane.fadeIn 200 - if paneId is "#preview" - $.post currentLink.data("preview-url"), - content: @editor.getValue() - , (response) -> - currentPane.empty().append response - currentPane.syntaxHighlight() - - else - @editor.focus() diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js new file mode 100644 index 00000000000..2cf0a6631b8 --- /dev/null +++ b/app/assets/javascripts/blob/template_selector.js @@ -0,0 +1,74 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.TemplateSelector = (function() { + function TemplateSelector(opts) { + var ref; + if (opts == null) { + opts = {}; + } + this.onClick = bind(this.onClick, this); + this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name'); + this.buildDropdown(); + this.bindEvents(); + this.onFilenameUpdate(); + } + + TemplateSelector.prototype.buildDropdown = function() { + return this.dropdown.glDropdown({ + data: this.data, + filterable: true, + selectable: true, + toggleLabel: this.toggleLabel, + search: { + fields: ['name'] + }, + clicked: this.onClick, + text: function(item) { + return item.name; + } + }); + }; + + TemplateSelector.prototype.bindEvents = function() { + return this.$input.on('keyup blur', (function(_this) { + return function(e) { + return _this.onFilenameUpdate(); + }; + })(this)); + }; + + TemplateSelector.prototype.toggleLabel = function(item) { + return item.name; + }; + + TemplateSelector.prototype.onFilenameUpdate = function() { + var filenameMatches; + if (!this.$input.length) { + return; + } + filenameMatches = this.pattern.test(this.$input.val().trim()); + if (!filenameMatches) { + this.wrapper.addClass('hidden'); + return; + } + return this.wrapper.removeClass('hidden'); + }; + + TemplateSelector.prototype.onClick = function(item, el, e) { + e.preventDefault(); + return this.requestFile(item); + }; + + TemplateSelector.prototype.requestFile = function(item) {}; + + TemplateSelector.prototype.requestFileSuccess = function(file) { + this.editor.setValue(file.content, 1); + return this.editor.focus(); + }; + + return TemplateSelector; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/blob/template_selector.js.coffee b/app/assets/javascripts/blob/template_selector.js.coffee deleted file mode 100644 index 40c9169beac..00000000000 --- a/app/assets/javascripts/blob/template_selector.js.coffee +++ /dev/null @@ -1,60 +0,0 @@ -class @TemplateSelector - constructor: (opts = {}) -> - { - @dropdown, - @data, - @pattern, - @wrapper, - @editor, - @fileEndpoint, - @$input = $('#file_name') - } = opts - - @buildDropdown() - @bindEvents() - @onFilenameUpdate() - - buildDropdown: -> - @dropdown.glDropdown( - data: @data, - filterable: true, - selectable: true, - toggleLabel: @toggleLabel, - search: - fields: ['name'] - clicked: @onClick - text: (item) -> - item.name - ) - - bindEvents: -> - @$input.on('keyup blur', (e) => - @onFilenameUpdate() - ) - - toggleLabel: (item) -> - item.name - - onFilenameUpdate: -> - return unless @$input.length - - filenameMatches = @pattern.test(@$input.val().trim()) - - if not filenameMatches - @wrapper.addClass('hidden') - return - - @wrapper.removeClass('hidden') - - onClick: (item, el, e) => - e.preventDefault() - @requestFile(item) - - requestFile: (item) -> - # To be implemented on the extending class - # e.g. - # Api.gitignoreText item.name, @requestFileSuccess.bind(@) - - requestFileSuccess: (file) -> - @editor.setValue(file.content, 1) - @editor.focus() diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee deleted file mode 100644 index 5457430f921..00000000000 --- a/app/assets/javascripts/breakpoints.coffee +++ /dev/null @@ -1,37 +0,0 @@ -class @Breakpoints - instance = null; - - class BreakpointInstance - BREAKPOINTS = ["xs", "sm", "md", "lg"] - - constructor: -> - @setup() - - setup: -> - allDeviceSelector = BREAKPOINTS.map (breakpoint) -> - ".device-#{breakpoint}" - return if $(allDeviceSelector.join(",")).length - - # Create all the elements - els = $.map BREAKPOINTS, (breakpoint) -> - "<div class='device-#{breakpoint} visible-#{breakpoint}'></div>" - $("body").append els.join('') - - visibleDevice: -> - allDeviceSelector = BREAKPOINTS.map (breakpoint) -> - ".device-#{breakpoint}" - $(allDeviceSelector.join(",")).filter(":visible") - - getBreakpointSize: -> - $visibleDevice = @visibleDevice - # the page refreshed via turbolinks - if not $visibleDevice().length - @setup() - $visibleDevice = @visibleDevice() - return $visibleDevice.attr("class").split("visible-")[1] - - @get: -> - return instance ?= new BreakpointInstance - -$ => - @bp = Breakpoints.get() diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js new file mode 100644 index 00000000000..1e0148e5798 --- /dev/null +++ b/app/assets/javascripts/breakpoints.js @@ -0,0 +1,68 @@ +(function() { + this.Breakpoints = (function() { + var BreakpointInstance, instance; + + function Breakpoints() {} + + instance = null; + + BreakpointInstance = (function() { + var BREAKPOINTS; + + BREAKPOINTS = ["xs", "sm", "md", "lg"]; + + function BreakpointInstance() { + this.setup(); + } + + BreakpointInstance.prototype.setup = function() { + var allDeviceSelector, els; + allDeviceSelector = BREAKPOINTS.map(function(breakpoint) { + return ".device-" + breakpoint; + }); + if ($(allDeviceSelector.join(",")).length) { + return; + } + els = $.map(BREAKPOINTS, function(breakpoint) { + return "<div class='device-" + breakpoint + " visible-" + breakpoint + "'></div>"; + }); + return $("body").append(els.join('')); + }; + + BreakpointInstance.prototype.visibleDevice = function() { + var allDeviceSelector; + allDeviceSelector = BREAKPOINTS.map(function(breakpoint) { + return ".device-" + breakpoint; + }); + return $(allDeviceSelector.join(",")).filter(":visible"); + }; + + BreakpointInstance.prototype.getBreakpointSize = function() { + var $visibleDevice; + $visibleDevice = this.visibleDevice; + if (!$visibleDevice().length) { + this.setup(); + } + $visibleDevice = this.visibleDevice(); + return $visibleDevice.attr("class").split("visible-")[1]; + }; + + return BreakpointInstance; + + })(); + + Breakpoints.get = function() { + return instance != null ? instance : instance = new BreakpointInstance; + }; + + return Breakpoints; + + })(); + + $((function(_this) { + return function() { + return _this.bp = Breakpoints.get(); + }; + })(this)); + +}).call(this); diff --git a/app/assets/javascripts/broadcast_message.js b/app/assets/javascripts/broadcast_message.js new file mode 100644 index 00000000000..fceeff36728 --- /dev/null +++ b/app/assets/javascripts/broadcast_message.js @@ -0,0 +1,34 @@ +(function() { + $(function() { + var previewPath; + $('input#broadcast_message_color').on('input', function() { + var previewColor; + previewColor = $(this).val(); + return $('div.broadcast-message-preview').css('background-color', previewColor); + }); + $('input#broadcast_message_font').on('input', function() { + var previewColor; + previewColor = $(this).val(); + return $('div.broadcast-message-preview').css('color', previewColor); + }); + previewPath = $('textarea#broadcast_message_message').data('preview-path'); + return $('textarea#broadcast_message_message').on('input', function() { + var message; + message = $(this).val(); + if (message === '') { + return $('.js-broadcast-message-preview').text("Your message here"); + } else { + return $.ajax({ + url: previewPath, + type: "POST", + data: { + broadcast_message: { + message: message + } + } + }); + } + }); + }); + +}).call(this); diff --git a/app/assets/javascripts/broadcast_message.js.coffee b/app/assets/javascripts/broadcast_message.js.coffee deleted file mode 100644 index a38a329c4c2..00000000000 --- a/app/assets/javascripts/broadcast_message.js.coffee +++ /dev/null @@ -1,22 +0,0 @@ -$ -> - $('input#broadcast_message_color').on 'input', -> - previewColor = $(@).val() - $('div.broadcast-message-preview').css('background-color', previewColor) - - $('input#broadcast_message_font').on 'input', -> - previewColor = $(@).val() - $('div.broadcast-message-preview').css('color', previewColor) - - previewPath = $('textarea#broadcast_message_message').data('preview-path') - - $('textarea#broadcast_message_message').on 'input', -> - message = $(@).val() - - if message == '' - $('.js-broadcast-message-preview').text("Your message here") - else - $.ajax( - url: previewPath - type: "POST" - data: { broadcast_message: { message: message } } - ) diff --git a/app/assets/javascripts/build.coffee b/app/assets/javascripts/build.coffee deleted file mode 100644 index cf203ea43a0..00000000000 --- a/app/assets/javascripts/build.coffee +++ /dev/null @@ -1,114 +0,0 @@ -class @Build - @interval: null - @state: null - - constructor: (@page_url, @build_url, @build_status, @state) -> - clearInterval(Build.interval) - - # Init breakpoint checker - @bp = Breakpoints.get() - @hideSidebar() - $('.js-build-sidebar').niceScroll() - $(document) - .off 'click', '.js-sidebar-build-toggle' - .on 'click', '.js-sidebar-build-toggle', @toggleSidebar - - $(window) - .off 'resize.build' - .on 'resize.build', @hideSidebar - - @updateArtifactRemoveDate() - - if $('#build-trace').length - @getInitialBuildTrace() - @initScrollButtonAffix() - - if @build_status is "running" or @build_status is "pending" - # - # Bind autoscroll button to follow build output - # - $('#autoscroll-button').on 'click', -> - state = $(this).data("state") - if "enabled" is state - $(this).data "state", "disabled" - $(this).text "enable autoscroll" - else - $(this).data "state", "enabled" - $(this).text "disable autoscroll" - - # - # Check for new build output if user still watching build page - # Only valid for runnig build when output changes during time - # - Build.interval = setInterval => - if window.location.href.split("#").first() is @page_url - @getBuildTrace() - , 4000 - - getInitialBuildTrace: -> - $.ajax - url: @build_url - dataType: 'json' - success: (build_data) -> - $('.js-build-output').html build_data.trace_html - - if build_data.status is 'success' or build_data.status is 'failed' - $('.js-build-refresh').remove() - - getBuildTrace: -> - $.ajax - url: "#{@page_url}/trace.json?state=#{encodeURIComponent(@state)}" - dataType: "json" - success: (log) => - if log.state - @state = log.state - - if log.status is "running" - if log.append - $('.js-build-output').append log.html - else - $('.js-build-output').html log.html - @checkAutoscroll() - else if log.status isnt @build_status - Turbolinks.visit @page_url - - checkAutoscroll: -> - $("html,body").scrollTop $("#build-trace").height() if "enabled" is $("#autoscroll-button").data("state") - - initScrollButtonAffix: -> - $buildScroll = $('#js-build-scroll') - $body = $('body') - $buildTrace = $('#build-trace') - - $buildScroll.affix( - offset: - bottom: -> - $body.outerHeight() - ($buildTrace.outerHeight() + $buildTrace.offset().top) - ) - - shouldHideSidebar: -> - bootstrapBreakpoint = @bp.getBreakpointSize() - - bootstrapBreakpoint is 'xs' or bootstrapBreakpoint is 'sm' - - toggleSidebar: => - if @shouldHideSidebar() - $('.js-build-sidebar') - .toggleClass 'right-sidebar-expanded right-sidebar-collapsed' - - hideSidebar: => - if @shouldHideSidebar() - $('.js-build-sidebar') - .removeClass 'right-sidebar-expanded' - .addClass 'right-sidebar-collapsed' - else - $('.js-build-sidebar') - .removeClass 'right-sidebar-collapsed' - .addClass 'right-sidebar-expanded' - - updateArtifactRemoveDate: -> - $date = $('.js-artifacts-remove') - - if $date.length - date = $date.text() - $date.text $.timefor(new Date(date), ' ') diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js new file mode 100644 index 00000000000..3d9b824d406 --- /dev/null +++ b/app/assets/javascripts/build.js @@ -0,0 +1,139 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Build = (function() { + Build.interval = null; + + Build.state = null; + + function Build(page_url, build_url, build_status, state1) { + this.page_url = page_url; + this.build_url = build_url; + this.build_status = build_status; + this.state = state1; + this.hideSidebar = bind(this.hideSidebar, this); + this.toggleSidebar = bind(this.toggleSidebar, this); + clearInterval(Build.interval); + this.bp = Breakpoints.get(); + this.hideSidebar(); + $('.js-build-sidebar').niceScroll(); + $(document).off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); + $(window).off('resize.build').on('resize.build', this.hideSidebar); + this.updateArtifactRemoveDate(); + if ($('#build-trace').length) { + this.getInitialBuildTrace(); + this.initScrollButtonAffix(); + } + if (this.build_status === "running" || this.build_status === "pending") { + $('#autoscroll-button').on('click', function() { + var state; + state = $(this).data("state"); + if ("enabled" === state) { + $(this).data("state", "disabled"); + return $(this).text("enable autoscroll"); + } else { + $(this).data("state", "enabled"); + return $(this).text("disable autoscroll"); + } + }); + Build.interval = setInterval((function(_this) { + return function() { + if (window.location.href.split("#").first() === _this.page_url) { + return _this.getBuildTrace(); + } + }; + })(this), 4000); + } + } + + Build.prototype.getInitialBuildTrace = function() { + return $.ajax({ + url: this.build_url, + dataType: 'json', + success: function(build_data) { + $('.js-build-output').html(build_data.trace_html); + if (build_data.status === 'success' || build_data.status === 'failed') { + return $('.js-build-refresh').remove(); + } + } + }); + }; + + Build.prototype.getBuildTrace = function() { + return $.ajax({ + url: this.page_url + "/trace.json?state=" + (encodeURIComponent(this.state)), + dataType: "json", + success: (function(_this) { + return function(log) { + if (log.state) { + _this.state = log.state; + } + if (log.status === "running") { + if (log.append) { + $('.js-build-output').append(log.html); + } else { + $('.js-build-output').html(log.html); + } + return _this.checkAutoscroll(); + } else if (log.status !== _this.build_status) { + return Turbolinks.visit(_this.page_url); + } + }; + })(this) + }); + }; + + Build.prototype.checkAutoscroll = function() { + if ("enabled" === $("#autoscroll-button").data("state")) { + return $("html,body").scrollTop($("#build-trace").height()); + } + }; + + Build.prototype.initScrollButtonAffix = function() { + var $body, $buildScroll, $buildTrace; + $buildScroll = $('#js-build-scroll'); + $body = $('body'); + $buildTrace = $('#build-trace'); + return $buildScroll.affix({ + offset: { + bottom: function() { + return $body.outerHeight() - ($buildTrace.outerHeight() + $buildTrace.offset().top); + } + } + }); + }; + + Build.prototype.shouldHideSidebar = function() { + var bootstrapBreakpoint; + bootstrapBreakpoint = this.bp.getBreakpointSize(); + return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; + }; + + Build.prototype.toggleSidebar = function() { + if (this.shouldHideSidebar()) { + return $('.js-build-sidebar').toggleClass('right-sidebar-expanded right-sidebar-collapsed'); + } + }; + + Build.prototype.hideSidebar = function() { + if (this.shouldHideSidebar()) { + return $('.js-build-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + } else { + return $('.js-build-sidebar').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded'); + } + }; + + Build.prototype.updateArtifactRemoveDate = function() { + var $date, date; + $date = $('.js-artifacts-remove'); + if ($date.length) { + date = $date.text(); + return $date.text($.timefor(new Date(date.replace(/-/g, '/')), ' ')); + } + }; + + return Build; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js new file mode 100644 index 00000000000..f345ba0abe6 --- /dev/null +++ b/app/assets/javascripts/build_artifacts.js @@ -0,0 +1,27 @@ +(function() { + this.BuildArtifacts = (function() { + function BuildArtifacts() { + this.disablePropagation(); + this.setupEntryClick(); + } + + BuildArtifacts.prototype.disablePropagation = function() { + $('.top-block').on('click', '.download', function(e) { + return e.stopPropagation(); + }); + return $('.tree-holder').on('click', 'tr[data-link] a', function(e) { + return e.stopImmediatePropagation(); + }); + }; + + BuildArtifacts.prototype.setupEntryClick = function() { + return $('.tree-holder').on('click', 'tr[data-link]', function(e) { + return window.location = this.dataset.link; + }); + }; + + return BuildArtifacts; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/build_artifacts.js.coffee b/app/assets/javascripts/build_artifacts.js.coffee deleted file mode 100644 index 5ae6cba56c8..00000000000 --- a/app/assets/javascripts/build_artifacts.js.coffee +++ /dev/null @@ -1,14 +0,0 @@ -class @BuildArtifacts - constructor: () -> - @disablePropagation() - @setupEntryClick() - - disablePropagation: -> - $('.top-block').on 'click', '.download', (e) -> - e.stopPropagation() - $('.tree-holder').on 'click', 'tr[data-link] a', (e) -> - e.stopImmediatePropagation() - - setupEntryClick: -> - $('.tree-holder').on 'click', 'tr[data-link]', (e) -> - window.location = @dataset.link diff --git a/app/assets/javascripts/commit.js b/app/assets/javascripts/commit.js new file mode 100644 index 00000000000..23cf5b519f4 --- /dev/null +++ b/app/assets/javascripts/commit.js @@ -0,0 +1,13 @@ +(function() { + this.Commit = (function() { + function Commit() { + $('.files .diff-file').each(function() { + return new CommitFile(this); + }); + } + + return Commit; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/commit.js.coffee b/app/assets/javascripts/commit.js.coffee deleted file mode 100644 index 0566e239191..00000000000 --- a/app/assets/javascripts/commit.js.coffee +++ /dev/null @@ -1,4 +0,0 @@ -class @Commit - constructor: -> - $('.files .diff-file').each -> - new CommitFile(this) diff --git a/app/assets/javascripts/commit/file.js b/app/assets/javascripts/commit/file.js new file mode 100644 index 00000000000..be24ee56aad --- /dev/null +++ b/app/assets/javascripts/commit/file.js @@ -0,0 +1,13 @@ +(function() { + this.CommitFile = (function() { + function CommitFile(file) { + if ($('.image', file).length) { + new ImageFile(file); + } + } + + return CommitFile; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/commit/file.js.coffee b/app/assets/javascripts/commit/file.js.coffee deleted file mode 100644 index 83e793863b6..00000000000 --- a/app/assets/javascripts/commit/file.js.coffee +++ /dev/null @@ -1,5 +0,0 @@ -class @CommitFile - - constructor: (file) -> - if $('.image', file).length - new ImageFile(file) diff --git a/app/assets/javascripts/commit/image-file.js b/app/assets/javascripts/commit/image-file.js new file mode 100644 index 00000000000..c0d0b2d049f --- /dev/null +++ b/app/assets/javascripts/commit/image-file.js @@ -0,0 +1,175 @@ +(function() { + this.ImageFile = (function() { + var prepareFrames; + + ImageFile.availWidth = 900; + + ImageFile.viewModes = ['two-up', 'swipe']; + + function ImageFile(file) { + this.file = file; + this.requestImageInfo($('.two-up.view .frame.deleted img', this.file), (function(_this) { + return function(deletedWidth, deletedHeight) { + return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), function(width, height) { + if (width === deletedWidth && height === deletedHeight) { + return _this.initViewModes(); + } else { + return _this.initView('two-up'); + } + }); + }; + })(this)); + } + + ImageFile.prototype.initViewModes = function() { + var viewMode; + viewMode = ImageFile.viewModes[0]; + $('.view-modes', this.file).removeClass('hide'); + $('.view-modes-menu', this.file).on('click', 'li', (function(_this) { + return function(event) { + if (!$(event.currentTarget).hasClass('active')) { + return _this.activateViewMode(event.currentTarget.className); + } + }; + })(this)); + return this.activateViewMode(viewMode); + }; + + ImageFile.prototype.activateViewMode = function(viewMode) { + $('.view-modes-menu li', this.file).removeClass('active').filter("." + viewMode).addClass('active'); + return $(".view:visible:not(." + viewMode + ")", this.file).fadeOut(200, (function(_this) { + return function() { + $(".view." + viewMode, _this.file).fadeIn(200); + return _this.initView(viewMode); + }; + })(this)); + }; + + ImageFile.prototype.initView = function(viewMode) { + return this.views[viewMode].call(this); + }; + + prepareFrames = function(view) { + var maxHeight, maxWidth; + maxWidth = 0; + maxHeight = 0; + $('.frame', view).each((function(_this) { + return function(index, frame) { + var height, width; + width = $(frame).width(); + height = $(frame).height(); + maxWidth = width > maxWidth ? width : maxWidth; + return maxHeight = height > maxHeight ? height : maxHeight; + }; + })(this)).css({ + width: maxWidth, + height: maxHeight + }); + return [maxWidth, maxHeight]; + }; + + ImageFile.prototype.views = { + 'two-up': function() { + return $('.two-up.view .wrap', this.file).each((function(_this) { + return function(index, wrap) { + $('img', wrap).each(function() { + var currentWidth; + currentWidth = $(this).width(); + if (currentWidth > ImageFile.availWidth / 2) { + return $(this).width(ImageFile.availWidth / 2); + } + }); + return _this.requestImageInfo($('img', wrap), function(width, height) { + $('.image-info .meta-width', wrap).text(width + "px"); + $('.image-info .meta-height', wrap).text(height + "px"); + return $('.image-info', wrap).removeClass('hide'); + }); + }; + })(this)); + }, + 'swipe': function() { + var maxHeight, maxWidth; + maxWidth = 0; + maxHeight = 0; + return $('.swipe.view', this.file).each((function(_this) { + return function(index, view) { + var ref; + ref = prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1]; + $('.swipe-frame', view).css({ + width: maxWidth + 16, + height: maxHeight + 28 + }); + $('.swipe-wrap', view).css({ + width: maxWidth + 1, + height: maxHeight + 2 + }); + return $('.swipe-bar', view).css({ + left: 0 + }).draggable({ + axis: 'x', + containment: 'parent', + drag: function(event) { + return $('.swipe-wrap', view).width((maxWidth + 1) - $(this).position().left); + }, + stop: function(event) { + return $('.swipe-wrap', view).width((maxWidth + 1) - $(this).position().left); + } + }); + }; + })(this)); + }, + 'onion-skin': function() { + var dragTrackWidth, maxHeight, maxWidth; + maxWidth = 0; + maxHeight = 0; + dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width(); + return $('.onion-skin.view', this.file).each((function(_this) { + return function(index, view) { + var ref; + ref = prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1]; + $('.onion-skin-frame', view).css({ + width: maxWidth + 16, + height: maxHeight + 28 + }); + $('.swipe-wrap', view).css({ + width: maxWidth + 1, + height: maxHeight + 2 + }); + return $('.dragger', view).css({ + left: dragTrackWidth + }).draggable({ + axis: 'x', + containment: 'parent', + drag: function(event) { + return $('.frame.added', view).css('opacity', $(this).position().left / dragTrackWidth); + }, + stop: function(event) { + return $('.frame.added', view).css('opacity', $(this).position().left / dragTrackWidth); + } + }); + }; + })(this)); + } + }; + + ImageFile.prototype.requestImageInfo = function(img, callback) { + var domImg; + domImg = img.get(0); + if (domImg) { + if (domImg.complete) { + return callback.call(this, domImg.naturalWidth, domImg.naturalHeight); + } else { + return img.on('load', (function(_this) { + return function() { + return callback.call(_this, domImg.naturalWidth, domImg.naturalHeight); + }; + })(this)); + } + } + }; + + return ImageFile; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/commit/image-file.js.coffee b/app/assets/javascripts/commit/image-file.js.coffee deleted file mode 100644 index 9c723f51e54..00000000000 --- a/app/assets/javascripts/commit/image-file.js.coffee +++ /dev/null @@ -1,127 +0,0 @@ -class @ImageFile - - # Width where images must fits in, for 2-up this gets divided by 2 - @availWidth = 900 - @viewModes = ['two-up', 'swipe'] - - constructor: (@file) -> - # Determine if old and new file has same dimensions, if not show 'two-up' view - this.requestImageInfo $('.two-up.view .frame.deleted img', @file), (deletedWidth, deletedHeight) => - this.requestImageInfo $('.two-up.view .frame.added img', @file), (width, height) => - if width == deletedWidth && height == deletedHeight - this.initViewModes() - else - this.initView('two-up') - - initViewModes: -> - viewMode = ImageFile.viewModes[0] - - $('.view-modes', @file).removeClass 'hide' - $('.view-modes-menu', @file).on 'click', 'li', (event) => - unless $(event.currentTarget).hasClass('active') - this.activateViewMode(event.currentTarget.className) - - this.activateViewMode(viewMode) - - activateViewMode: (viewMode) -> - $('.view-modes-menu li', @file) - .removeClass('active') - .filter(".#{viewMode}").addClass 'active' - $(".view:visible:not(.#{viewMode})", @file).fadeOut 200, => - $(".view.#{viewMode}", @file).fadeIn(200) - this.initView viewMode - - initView: (viewMode) -> - this.views[viewMode].call(this) - - prepareFrames = (view) -> - maxWidth = 0 - maxHeight = 0 - $('.frame', view).each (index, frame) => - width = $(frame).width() - height = $(frame).height() - maxWidth = if width > maxWidth then width else maxWidth - maxHeight = if height > maxHeight then height else maxHeight - .css - width: maxWidth - height: maxHeight - - [maxWidth, maxHeight] - - views: - 'two-up': -> - $('.two-up.view .wrap', @file).each (index, wrap) => - $('img', wrap).each -> - currentWidth = $(this).width() - if currentWidth > ImageFile.availWidth / 2 - $(this).width ImageFile.availWidth / 2 - - this.requestImageInfo $('img', wrap), (width, height) -> - $('.image-info .meta-width', wrap).text "#{width}px" - $('.image-info .meta-height', wrap).text "#{height}px" - $('.image-info', wrap).removeClass('hide') - - 'swipe': -> - maxWidth = 0 - maxHeight = 0 - - $('.swipe.view', @file).each (index, view) => - - [maxWidth, maxHeight] = prepareFrames(view) - - $('.swipe-frame', view).css - width: maxWidth + 16 - height: maxHeight + 28 - - $('.swipe-wrap', view).css - width: maxWidth + 1 - height: maxHeight + 2 - - $('.swipe-bar', view).css - left: 0 - .draggable - axis: 'x' - containment: 'parent' - drag: (event) -> - $('.swipe-wrap', view).width (maxWidth + 1) - $(this).position().left - stop: (event) -> - $('.swipe-wrap', view).width (maxWidth + 1) - $(this).position().left - - 'onion-skin': -> - maxWidth = 0 - maxHeight = 0 - - dragTrackWidth = $('.drag-track', @file).width() - $('.dragger', @file).width() - - $('.onion-skin.view', @file).each (index, view) => - - [maxWidth, maxHeight] = prepareFrames(view) - - $('.onion-skin-frame', view).css - width: maxWidth + 16 - height: maxHeight + 28 - - $('.swipe-wrap', view).css - width: maxWidth + 1 - height: maxHeight + 2 - - $('.dragger', view).css - left: dragTrackWidth - .draggable - axis: 'x' - containment: 'parent' - drag: (event) -> - $('.frame.added', view).css('opacity', $(this).position().left / dragTrackWidth) - stop: (event) -> - $('.frame.added', view).css('opacity', $(this).position().left / dragTrackWidth) - - - - requestImageInfo: (img, callback) -> - domImg = img.get(0) - if domImg - if domImg.complete - callback.call(this, domImg.naturalWidth, domImg.naturalHeight) - else - img.on 'load', => - callback.call(this, domImg.naturalWidth, domImg.naturalHeight) diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js new file mode 100644 index 00000000000..37f168c5190 --- /dev/null +++ b/app/assets/javascripts/commits.js @@ -0,0 +1,58 @@ +(function() { + this.CommitsList = (function() { + function CommitsList() {} + + CommitsList.timer = null; + + CommitsList.init = function(limit) { + $("body").on("click", ".day-commits-table li.commit", function(event) { + if (event.target.nodeName !== "A") { + location.href = $(this).attr("url"); + e.stopPropagation(); + return false; + } + }); + Pager.init(limit, false); + this.content = $("#commits-list"); + this.searchField = $("#commits-search"); + return this.initSearch(); + }; + + CommitsList.initSearch = function() { + this.timer = null; + return this.searchField.keyup((function(_this) { + return function() { + clearTimeout(_this.timer); + return _this.timer = setTimeout(_this.filterResults, 500); + }; + })(this)); + }; + + CommitsList.filterResults = function() { + var commitsUrl, form, search; + form = $(".commits-search-form"); + search = CommitsList.searchField.val(); + commitsUrl = form.attr("action") + '?' + form.serialize(); + CommitsList.content.fadeTo('fast', 0.5); + return $.ajax({ + type: "GET", + url: form.attr("action"), + data: form.serialize(), + complete: function() { + return CommitsList.content.fadeTo('fast', 1.0); + }, + success: function(data) { + CommitsList.content.html(data.html); + return history.replaceState({ + page: commitsUrl + }, document.title, commitsUrl); + }, + dataType: "json" + }); + }; + + return CommitsList; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/commits.js.coffee b/app/assets/javascripts/commits.js.coffee deleted file mode 100644 index 0acb4c1955e..00000000000 --- a/app/assets/javascripts/commits.js.coffee +++ /dev/null @@ -1,39 +0,0 @@ -class @CommitsList - @timer = null - - @init: (limit) -> - $("body").on "click", ".day-commits-table li.commit", (event) -> - if event.target.nodeName != "A" - location.href = $(this).attr("url") - e.stopPropagation() - return false - - Pager.init limit, false - - @content = $("#commits-list") - @searchField = $("#commits-search") - @initSearch() - - @initSearch: -> - @timer = null - @searchField.keyup => - clearTimeout(@timer) - @timer = setTimeout(@filterResults, 500) - - @filterResults: => - form = $(".commits-search-form") - search = @searchField.val() - commitsUrl = form.attr("action") + '?' + form.serialize() - @content.fadeTo('fast', 0.5) - - $.ajax - type: "GET" - url: form.attr("action") - data: form.serialize() - complete: => - @content.fadeTo('fast', 1.0) - success: (data) => - @content.html(data.html) - # Change url so if user reload a page - search results are saved - history.replaceState {page: commitsUrl}, document.title, commitsUrl - dataType: "json" diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js new file mode 100644 index 00000000000..342ac0e8e69 --- /dev/null +++ b/app/assets/javascripts/compare.js @@ -0,0 +1,91 @@ +(function() { + this.Compare = (function() { + function Compare(opts) { + this.opts = opts; + this.source_loading = $(".js-source-loading"); + this.target_loading = $(".js-target-loading"); + $('.js-compare-dropdown').each((function(_this) { + return function(i, dropdown) { + var $dropdown; + $dropdown = $(dropdown); + return $dropdown.glDropdown({ + selectable: true, + fieldName: $dropdown.data('field-name'), + filterable: true, + id: function(obj, $el) { + return $el.data('id'); + }, + toggleLabel: function(obj, $el) { + return $el.text().trim(); + }, + clicked: function(e, el) { + if ($dropdown.is('.js-target-branch')) { + return _this.getTargetHtml(); + } else if ($dropdown.is('.js-source-branch')) { + return _this.getSourceHtml(); + } else if ($dropdown.is('.js-target-project')) { + return _this.getTargetProject(); + } + } + }); + }; + })(this)); + this.initialState(); + } + + Compare.prototype.initialState = function() { + this.getSourceHtml(); + return this.getTargetHtml(); + }; + + Compare.prototype.getTargetProject = function() { + return $.ajax({ + url: this.opts.targetProjectUrl, + data: { + target_project_id: $("input[name='merge_request[target_project_id]']").val() + }, + beforeSend: function() { + return $('.mr_target_commit').empty(); + }, + success: function(html) { + return $('.js-target-branch-dropdown .dropdown-content').html(html); + } + }); + }; + + Compare.prototype.getSourceHtml = function() { + return this.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', { + ref: $("input[name='merge_request[source_branch]']").val() + }); + }; + + Compare.prototype.getTargetHtml = function() { + return this.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', { + target_project_id: $("input[name='merge_request[target_project_id]']").val(), + ref: $("input[name='merge_request[target_branch]']").val() + }); + }; + + Compare.prototype.sendAjax = function(url, loading, target, data) { + var $target; + $target = $(target); + return $.ajax({ + url: url, + data: data, + beforeSend: function() { + loading.show(); + return $target.empty(); + }, + success: function(html) { + loading.hide(); + $target.html(html); + return $('.js-timeago', $target).timeago(); + } + }); + }; + + return Compare; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/compare.js.coffee b/app/assets/javascripts/compare.js.coffee deleted file mode 100644 index f20992ead3e..00000000000 --- a/app/assets/javascripts/compare.js.coffee +++ /dev/null @@ -1,67 +0,0 @@ -class @Compare - constructor: (@opts) -> - @source_loading = $ ".js-source-loading" - @target_loading = $ ".js-target-loading" - - $('.js-compare-dropdown').each (i, dropdown) => - $dropdown = $(dropdown) - - $dropdown.glDropdown( - selectable: true - fieldName: $dropdown.data 'field-name' - filterable: true - id: (obj, $el) -> - $el.data 'id' - toggleLabel: (obj, $el) -> - $el.text().trim() - clicked: (e, el) => - if $dropdown.is '.js-target-branch' - @getTargetHtml() - else if $dropdown.is '.js-source-branch' - @getSourceHtml() - else if $dropdown.is '.js-target-project' - @getTargetProject() - ) - - @initialState() - - initialState: -> - @getSourceHtml() - @getTargetHtml() - - getTargetProject: -> - $.ajax( - url: @opts.targetProjectUrl - data: - target_project_id: $("input[name='merge_request[target_project_id]']").val() - beforeSend: -> - $('.mr_target_commit').empty() - success: (html) -> - $('.js-target-branch-dropdown .dropdown-content').html html - ) - - getSourceHtml: -> - @sendAjax(@opts.sourceBranchUrl, @source_loading, '.mr_source_commit', - ref: $("input[name='merge_request[source_branch]']").val() - ) - - getTargetHtml: -> - @sendAjax(@opts.targetBranchUrl, @target_loading, '.mr_target_commit', - target_project_id: $("input[name='merge_request[target_project_id]']").val() - ref: $("input[name='merge_request[target_branch]']").val() - ) - - sendAjax: (url, loading, target, data) -> - $target = $(target) - - $.ajax( - url: url - data: data - beforeSend: -> - loading.show() - $target.empty() - success: (html) -> - loading.hide() - $target.html html - $('.js-timeago', $target).timeago() - ) diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js new file mode 100644 index 00000000000..4e3a28cd163 --- /dev/null +++ b/app/assets/javascripts/compare_autocomplete.js @@ -0,0 +1,51 @@ +(function() { + this.CompareAutocomplete = (function() { + function CompareAutocomplete() { + this.initDropdown(); + } + + CompareAutocomplete.prototype.initDropdown = function() { + return $('.js-compare-dropdown').each(function() { + var $dropdown, selected; + $dropdown = $(this); + selected = $dropdown.data('selected'); + return $dropdown.glDropdown({ + data: function(term, callback) { + return $.ajax({ + url: $dropdown.data('refs-url'), + data: { + ref: $dropdown.data('ref') + } + }).done(function(refs) { + return callback(refs); + }); + }, + selectable: true, + filterable: true, + filterByText: true, + fieldName: $dropdown.attr('name'), + filterInput: 'input[type="text"]', + renderRow: function(ref) { + var link; + if (ref.header != null) { + return $('<li />').addClass('dropdown-header').text(ref.header); + } else { + link = $('<a />').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref)); + return $('<li />').append(link); + } + }, + id: function(obj, $el) { + return $el.attr('data-ref'); + }, + toggleLabel: function(obj, $el) { + return $el.text().trim(); + } + }); + }); + }; + + return CompareAutocomplete; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/compare_autocomplete.js.coffee b/app/assets/javascripts/compare_autocomplete.js.coffee deleted file mode 100644 index 7ad9fd97637..00000000000 --- a/app/assets/javascripts/compare_autocomplete.js.coffee +++ /dev/null @@ -1,41 +0,0 @@ -class @CompareAutocomplete - constructor: -> - @initDropdown() - - initDropdown: -> - $('.js-compare-dropdown').each -> - $dropdown = $(@) - selected = $dropdown.data('selected') - - $dropdown.glDropdown( - data: (term, callback) -> - $.ajax( - url: $dropdown.data('refs-url') - data: - ref: $dropdown.data('ref') - ).done (refs) -> - callback(refs) - selectable: true - filterable: true - filterByText: true - fieldName: $dropdown.attr('name') - filterInput: 'input[type="text"]' - renderRow: (ref) -> - if ref.header? - $('<li />') - .addClass('dropdown-header') - .text(ref.header) - else - link = $('<a />') - .attr('href', '#') - .addClass(if ref is selected then 'is-active' else '') - .text(ref) - .attr('data-ref', escape(ref)) - - $('<li />') - .append(link) - id: (obj, $el) -> - $el.attr('data-ref') - toggleLabel: (obj, $el) -> - $el.text().trim() - ) diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js new file mode 100644 index 00000000000..708ab08ffac --- /dev/null +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -0,0 +1,32 @@ +(function() { + this.ConfirmDangerModal = (function() { + function ConfirmDangerModal(form, text) { + var project_path, submit; + this.form = form; + $('.js-confirm-text').text(text || ''); + $('.js-confirm-danger-input').val(''); + $('#modal-confirm-danger').modal('show'); + project_path = $('.js-confirm-danger-match').text(); + submit = $('.js-confirm-danger-submit'); + submit.disable(); + $('.js-confirm-danger-input').off('input'); + $('.js-confirm-danger-input').on('input', function() { + if (rstrip($(this).val()) === project_path) { + return submit.enable(); + } else { + return submit.disable(); + } + }); + $('.js-confirm-danger-submit').off('click'); + $('.js-confirm-danger-submit').on('click', (function(_this) { + return function() { + return _this.form.submit(); + }; + })(this)); + } + + return ConfirmDangerModal; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/confirm_danger_modal.js.coffee b/app/assets/javascripts/confirm_danger_modal.js.coffee deleted file mode 100644 index 66e34dd4a08..00000000000 --- a/app/assets/javascripts/confirm_danger_modal.js.coffee +++ /dev/null @@ -1,20 +0,0 @@ -class @ConfirmDangerModal - constructor: (form, text) -> - @form = form - $('.js-confirm-text').text(text || '') - $('.js-confirm-danger-input').val('') - $('#modal-confirm-danger').modal('show') - project_path = $('.js-confirm-danger-match').text() - submit = $('.js-confirm-danger-submit') - submit.disable() - - $('.js-confirm-danger-input').off 'input' - $('.js-confirm-danger-input').on 'input', -> - if rstrip($(@).val()) is project_path - submit.enable() - else - submit.disable() - - $('.js-confirm-danger-submit').off 'click' - $('.js-confirm-danger-submit').on 'click', => - @form.submit() diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js new file mode 100644 index 00000000000..c82798cc6a5 --- /dev/null +++ b/app/assets/javascripts/copy_to_clipboard.js @@ -0,0 +1,42 @@ + +/*= require clipboard */ + +(function() { + var genericError, genericSuccess, showTooltip; + + genericSuccess = function(e) { + showTooltip(e.trigger, 'Copied!'); + e.clearSelection(); + return $(e.trigger).blur(); + }; + + genericError = function(e) { + var key; + if (/Mac/i.test(navigator.userAgent)) { + key = '⌘'; + } else { + key = 'Ctrl'; + } + return showTooltip(e.trigger, "Press " + key + "-C to copy"); + }; + + showTooltip = function(target, title) { + return $(target).tooltip({ + container: 'body', + html: 'true', + placement: 'auto bottom', + title: title, + trigger: 'manual' + }).tooltip('show').one('mouseleave', function() { + return $(this).tooltip('hide'); + }); + }; + + $(function() { + var clipboard; + clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); + clipboard.on('success', genericSuccess); + return clipboard.on('error', genericError); + }); + +}).call(this); diff --git a/app/assets/javascripts/copy_to_clipboard.js.coffee b/app/assets/javascripts/copy_to_clipboard.js.coffee deleted file mode 100644 index 24301e01b10..00000000000 --- a/app/assets/javascripts/copy_to_clipboard.js.coffee +++ /dev/null @@ -1,37 +0,0 @@ -#= require clipboard - -genericSuccess = (e) -> - showTooltip(e.trigger, 'Copied!') - - # Clear the selection and blur the trigger so it loses its border - e.clearSelection() - $(e.trigger).blur() - -# Safari doesn't support `execCommand`, so instead we inform the user to -# copy manually. -# -# See http://clipboardjs.com/#browser-support -genericError = (e) -> - if /Mac/i.test(navigator.userAgent) - key = '⌘' # Command - else - key = 'Ctrl' - - showTooltip(e.trigger, "Press #{key}-C to copy") - -showTooltip = (target, title) -> - $(target). - tooltip( - container: 'body' - html: 'true' - placement: 'auto bottom' - title: title - trigger: 'manual' - ). - tooltip('show'). - one('mouseleave', -> $(this).tooltip('hide')) - -$ -> - clipboard = new Clipboard '[data-clipboard-target], [data-clipboard-text]' - clipboard.on 'success', genericSuccess - clipboard.on 'error', genericError diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js new file mode 100644 index 00000000000..3dd7ceba92f --- /dev/null +++ b/app/assets/javascripts/diff.js @@ -0,0 +1,66 @@ +(function() { + this.Diff = (function() { + var UNFOLD_COUNT; + + UNFOLD_COUNT = 20; + + function Diff() { + $('.files .diff-file').singleFileDiff(); + this.filesCommentButton = $('.files .diff-file').filesCommentButton(); + $(document).off('click', '.js-unfold'); + $(document).on('click', '.js-unfold', (function(_this) { + return function(event) { + var line_number, link, file, offset, old_line, params, prev_new_line, prev_old_line, ref, ref1, since, target, to, unfold, unfoldBottom; + target = $(event.target); + unfoldBottom = target.hasClass('js-unfold-bottom'); + unfold = true; + ref = _this.lineNumbers(target.parent()), old_line = ref[0], line_number = ref[1]; + offset = line_number - old_line; + if (unfoldBottom) { + line_number += 1; + since = line_number; + to = line_number + UNFOLD_COUNT; + } else { + ref1 = _this.lineNumbers(target.parent().prev()), prev_old_line = ref1[0], prev_new_line = ref1[1]; + line_number -= 1; + to = line_number; + if (line_number - UNFOLD_COUNT > prev_new_line + 1) { + since = line_number - UNFOLD_COUNT; + } else { + since = prev_new_line + 1; + unfold = false; + } + } + file = target.parents('.diff-file'); + link = file.data('blob-diff-path'); + params = { + since: since, + to: to, + bottom: unfoldBottom, + offset: offset, + unfold: unfold, + indent: 1, + view: file.data('view') + }; + return $.get(link, params, function(response) { + return target.parent().replaceWith(response); + }); + }; + })(this)); + } + + Diff.prototype.lineNumbers = function(line) { + if (!line.children().length) { + return [0, 0]; + } + + return line.find('.diff-line-num').map(function() { + return parseInt($(this).data('linenumber')); + }); + }; + + return Diff; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/diff.js.coffee b/app/assets/javascripts/diff.js.coffee deleted file mode 100644 index c132cc8c542..00000000000 --- a/app/assets/javascripts/diff.js.coffee +++ /dev/null @@ -1,51 +0,0 @@ -class @Diff - UNFOLD_COUNT = 20 - constructor: -> - $('.files .diff-file').singleFileDiff() - @filesCommentButton = $('.files .diff-file').filesCommentButton() - - $(document).off('click', '.js-unfold') - $(document).on('click', '.js-unfold', (event) => - target = $(event.target) - unfoldBottom = target.hasClass('js-unfold-bottom') - unfold = true - - [old_line, line_number] = @lineNumbers(target.parent()) - offset = line_number - old_line - - if unfoldBottom - line_number += 1 - since = line_number - to = line_number + UNFOLD_COUNT - else - [prev_old_line, prev_new_line] = @lineNumbers(target.parent().prev()) - line_number -= 1 - to = line_number - if line_number - UNFOLD_COUNT > prev_new_line + 1 - since = line_number - UNFOLD_COUNT - else - since = prev_new_line + 1 - unfold = false - - link = target.parents('.diff-file').attr('data-blob-diff-path') - params = - since: since - to: to - bottom: unfoldBottom - offset: offset - unfold: unfold - # indent is used to compensate for single space indent to fit - # '+' and '-' prepended to diff lines, - # see https://gitlab.com/gitlab-org/gitlab-ce/issues/707 - indent: 1 - - $.get(link, params, (response) -> - target.parent().replaceWith(response) - ) - ) - - lineNumbers: (line) -> - return ([0, 0]) unless line.children().length - lines = line.children().slice(0, 2) - line_numbers = ($(l).attr('data-linenumber') for l in lines) - (parseInt(line_number) for line_number in line_numbers) diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js new file mode 100644 index 00000000000..20f2b1d69b5 --- /dev/null +++ b/app/assets/javascripts/dispatcher.js @@ -0,0 +1,260 @@ +(function() { + var Dispatcher; + + $(function() { + return new Dispatcher(); + }); + + Dispatcher = (function() { + function Dispatcher() { + this.initSearch(); + this.initPageScripts(); + } + + Dispatcher.prototype.initPageScripts = function() { + var page, path, shortcut_handler; + page = $('body').attr('data-page'); + if (!page) { + return false; + } + path = page.split(':'); + shortcut_handler = null; + switch (page) { + case 'projects:issues:index': + Issuable.init(); + new IssuableBulkActions(); + shortcut_handler = new ShortcutsNavigation(); + break; + case 'projects:issues:show': + new Issue(); + shortcut_handler = new ShortcutsIssuable(); + new ZenMode(); + break; + case 'projects:milestones:show': + case 'groups:milestones:show': + case 'dashboard:milestones:show': + new Milestone(); + break; + case 'dashboard:todos:index': + new Todos(); + break; + case 'projects:milestones:new': + case 'projects:milestones:edit': + new ZenMode(); + new DueDateSelect(); + new GLForm($('.milestone-form')); + break; + case 'groups:milestones:new': + new ZenMode(); + break; + case 'projects:compare:show': + new Diff(); + break; + case 'projects:issues:new': + case 'projects:issues:edit': + shortcut_handler = new ShortcutsNavigation(); + new GLForm($('.issue-form')); + new IssuableForm($('.issue-form')); + break; + case 'projects:merge_requests:new': + case 'projects:merge_requests:edit': + new Diff(); + shortcut_handler = new ShortcutsNavigation(); + new GLForm($('.merge-request-form')); + new IssuableForm($('.merge-request-form')); + break; + case 'projects:tags:new': + new ZenMode(); + new GLForm($('.tag-form')); + break; + case 'projects:releases:edit': + new ZenMode(); + new GLForm($('.release-form')); + break; + case 'projects:merge_requests:show': + new Diff(); + shortcut_handler = new ShortcutsIssuable(true); + new ZenMode(); + new MergedButtons(); + break; + case 'projects:merge_requests:commits': + case 'projects:merge_requests:builds': + new MergedButtons(); + break; + case "projects:merge_requests:diffs": + new Diff(); + new ZenMode(); + new MergedButtons(); + break; + case 'projects:merge_requests:index': + shortcut_handler = new ShortcutsNavigation(); + Issuable.init(); + break; + case 'dashboard:activity': + new Activities(); + break; + case 'dashboard:projects:starred': + new Activities(); + break; + case 'projects:commit:show': + new Commit(); + new Diff(); + new ZenMode(); + shortcut_handler = new ShortcutsNavigation(); + break; + case 'projects:commits:show': + case 'projects:activity': + shortcut_handler = new ShortcutsNavigation(); + break; + case 'projects:show': + shortcut_handler = new ShortcutsNavigation(); + new NotificationsForm(); + if ($('#tree-slider').length) { + new TreeView(); + } + break; + case 'groups:activity': + new Activities(); + break; + case 'groups:show': + shortcut_handler = new ShortcutsNavigation(); + new NotificationsForm(); + new NotificationsDropdown(); + break; + case 'groups:group_members:index': + new GroupMembers(); + new UsersSelect(); + break; + case 'projects:project_members:index': + new ProjectMembers(); + new UsersSelect(); + break; + case 'groups:new': + case 'groups:edit': + case 'admin:groups:edit': + case 'admin:groups:new': + new GroupAvatar(); + break; + case 'projects:tree:show': + shortcut_handler = new ShortcutsNavigation(); + new TreeView(); + break; + case 'projects:find_file:show': + shortcut_handler = true; + break; + case 'projects:blob:show': + case 'projects:blame:show': + new LineHighlighter(); + shortcut_handler = new ShortcutsNavigation(); + new ShortcutsBlob(true); + break; + case 'projects:labels:new': + case 'projects:labels:edit': + new Labels(); + break; + case 'projects:labels:index': + if ($('.prioritized-labels').length) { + new LabelManager(); + } + break; + case 'projects:network:show': + shortcut_handler = true; + break; + case 'projects:forks:new': + new ProjectFork(); + break; + case 'projects:artifacts:browse': + new BuildArtifacts(); + break; + case 'projects:group_links:index': + new GroupsSelect(); + break; + case 'search:show': + new Search(); + break; + case 'projects:protected_branches:index': + new gl.ProtectedBranchCreate(); + new gl.ProtectedBranchEditList(); + break; + } + switch (path.first()) { + case 'admin': + new Admin(); + switch (path[1]) { + case 'groups': + new UsersSelect(); + break; + case 'projects': + new NamespaceSelects(); + } + break; + case 'dashboard': + case 'root': + shortcut_handler = new ShortcutsDashboardNavigation(); + break; + case 'profiles': + new NotificationsForm(); + new NotificationsDropdown(); + break; + case 'projects': + new Project(); + new ProjectAvatar(); + switch (path[1]) { + case 'compare': + new CompareAutocomplete(); + break; + case 'edit': + shortcut_handler = new ShortcutsNavigation(); + new ProjectNew(); + break; + case 'new': + new ProjectNew(); + break; + case 'show': + new ProjectNew(); + new ProjectShow(); + new NotificationsDropdown(); + break; + case 'wikis': + new Wikis(); + shortcut_handler = new ShortcutsNavigation(); + new ZenMode(); + new GLForm($('.wiki-form')); + break; + case 'snippets': + shortcut_handler = new ShortcutsNavigation(); + if (path[2] === 'show') { + new ZenMode(); + } + break; + case 'labels': + case 'graphs': + case 'compare': + case 'pipelines': + case 'forks': + case 'milestones': + case 'project_members': + case 'deploy_keys': + case 'builds': + case 'hooks': + case 'services': + case 'protected_branches': + shortcut_handler = new ShortcutsNavigation(); + } + } + if (!shortcut_handler) { + return new Shortcuts(); + } + }; + + Dispatcher.prototype.initSearch = function() { + if ($('.search').length) { + return new SearchAutocomplete(); + } + }; + + return Dispatcher; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee deleted file mode 100644 index afaa6407b05..00000000000 --- a/app/assets/javascripts/dispatcher.js.coffee +++ /dev/null @@ -1,171 +0,0 @@ -$ -> - new Dispatcher() - -class Dispatcher - constructor: () -> - @initSearch() - @initPageScripts() - - initPageScripts: -> - page = $('body').attr('data-page') - - unless page - return false - - path = page.split(':') - shortcut_handler = null - switch page - when 'projects:issues:index' - Issuable.init() - new IssuableBulkActions() - shortcut_handler = new ShortcutsNavigation() - when 'projects:issues:show' - new Issue() - shortcut_handler = new ShortcutsIssuable() - new ZenMode() - when 'projects:milestones:show', 'groups:milestones:show', 'dashboard:milestones:show' - new Milestone() - when 'dashboard:todos:index' - new Todos() - when 'projects:milestones:new', 'projects:milestones:edit' - new ZenMode() - new DueDateSelect() - new GLForm($('.milestone-form')) - when 'groups:milestones:new' - new ZenMode() - when 'projects:compare:show' - new Diff() - when 'projects:issues:new','projects:issues:edit' - shortcut_handler = new ShortcutsNavigation() - new GLForm($('.issue-form')) - new IssuableForm($('.issue-form')) - when 'projects:merge_requests:new', 'projects:merge_requests:edit' - new Diff() - shortcut_handler = new ShortcutsNavigation() - new GLForm($('.merge-request-form')) - new IssuableForm($('.merge-request-form')) - when 'projects:tags:new' - new ZenMode() - new GLForm($('.tag-form')) - when 'projects:releases:edit' - new ZenMode() - new GLForm($('.release-form')) - when 'projects:merge_requests:show' - new Diff() - shortcut_handler = new ShortcutsIssuable(true) - new ZenMode() - new MergedButtons() - when 'projects:merge_requests:commits', 'projects:merge_requests:builds' - new MergedButtons() - when "projects:merge_requests:diffs" - new Diff() - new ZenMode() - new MergedButtons() - when 'projects:merge_requests:index' - shortcut_handler = new ShortcutsNavigation() - Issuable.init() - when 'dashboard:activity' - new Activities() - when 'dashboard:projects:starred' - new Activities() - when 'projects:commit:show' - new Commit() - new Diff() - new ZenMode() - shortcut_handler = new ShortcutsNavigation() - when 'projects:commits:show', 'projects:activity' - shortcut_handler = new ShortcutsNavigation() - when 'projects:show' - shortcut_handler = new ShortcutsNavigation() - - new NotificationsForm() - new TreeView() if $('#tree-slider').length - when 'groups:activity' - new Activities() - when 'groups:show' - shortcut_handler = new ShortcutsNavigation() - new NotificationsForm() - new NotificationsDropdown() - when 'groups:group_members:index' - new GroupMembers() - new UsersSelect() - when 'projects:project_members:index' - new ProjectMembers() - new UsersSelect() - when 'groups:new', 'groups:edit', 'admin:groups:edit', 'admin:groups:new' - new GroupAvatar() - when 'projects:tree:show' - shortcut_handler = new ShortcutsNavigation() - new TreeView() - when 'projects:find_file:show' - shortcut_handler = true - when 'projects:blob:show', 'projects:blame:show' - new LineHighlighter() - shortcut_handler = new ShortcutsNavigation() - new ShortcutsBlob true - when 'projects:labels:new', 'projects:labels:edit' - new Labels() - when 'projects:labels:index' - new LabelManager() if $('.prioritized-labels').length - when 'projects:network:show' - # Ensure we don't create a particular shortcut handler here. This is - # already created, where the network graph is created. - shortcut_handler = true - when 'projects:forks:new' - new ProjectFork() - when 'projects:artifacts:browse' - new BuildArtifacts() - when 'projects:group_links:index' - new GroupsSelect() - when 'search:show' - new Search() - - switch path.first() - when 'admin' - new Admin() - switch path[1] - when 'groups' - new UsersSelect() - when 'projects' - new NamespaceSelects() - when 'dashboard', 'root' - shortcut_handler = new ShortcutsDashboardNavigation() - when 'profiles' - new NotificationsForm() - new NotificationsDropdown() - when 'projects' - new Project() - new ProjectAvatar() - switch path[1] - when 'compare' - new CompareAutocomplete() - when 'edit' - shortcut_handler = new ShortcutsNavigation() - new ProjectNew() - when 'new' - new ProjectNew() - when 'show' - new ProjectNew() - new ProjectShow() - new NotificationsDropdown() - when 'wikis' - new Wikis() - shortcut_handler = new ShortcutsNavigation() - new ZenMode() - new GLForm($('.wiki-form')) - when 'snippets' - shortcut_handler = new ShortcutsNavigation() - new ZenMode() if path[2] == 'show' - when 'labels', 'graphs', 'compare', 'pipelines', 'forks', \ - 'milestones', 'project_members', 'deploy_keys', 'builds', \ - 'hooks', 'services', 'protected_branches' - shortcut_handler = new ShortcutsNavigation() - - # If we haven't installed a custom shortcut handler, install the default one - if not shortcut_handler - new Shortcuts() - - initSearch: -> - - # Only when search form is present - new SearchAutocomplete() if $('.search').length diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js new file mode 100644 index 00000000000..288cce04f87 --- /dev/null +++ b/app/assets/javascripts/dropzone_input.js @@ -0,0 +1,219 @@ + +/*= require markdown_preview */ + +(function() { + this.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; + Dropzone.autoDiscover = false; + alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; + alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; + divHover = "<div class=\"div-dropzone-hover\"></div>"; + divSpinner = "<div class=\"div-dropzone-spinner\"></div>"; + divAlert = "<div class=\"" + alertClass + "\"></div>"; + iconPaperclip = "<i class=\"fa fa-paperclip div-dropzone-icon\"></i>"; + 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; + max_file_size = gon.max_file_size || 10; + form_textarea = $(form).find(".js-gfm-input"); + form_textarea.wrap("<div class=\"div-dropzone\"></div>"); + form_textarea.on('paste', (function(_this) { + return function(event) { + return handlePaste(event); + }; + })(this)); + $mdArea = $(form_textarea).closest('.md-area'); + $(form).setupMarkdownPreview(); + form_dropzone = $(form).find('.div-dropzone'); + form_dropzone.parent().addClass("div-dropzone-wrapper"); + form_dropzone.append(divHover); + form_dropzone.find(".div-dropzone-hover").append(iconPaperclip); + form_dropzone.append(divSpinner); + form_dropzone.find(".div-dropzone-spinner").append(iconSpinner); + form_dropzone.find(".div-dropzone-spinner").append(uploadProgress); + form_dropzone.find(".div-dropzone-spinner").css({ + "opacity": 0, + "display": "none" + }); + dropzone = form_dropzone.dropzone({ + url: project_uploads_path, + dictDefaultMessage: "", + clickable: true, + paramName: "file", + maxFilesize: max_file_size, + uploadMultiple: false, + headers: { + "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + }, + previewContainer: false, + processing: function() { + return $(".div-dropzone-alert").alert("close"); + }, + dragover: function() { + $mdArea.addClass('is-dropzone-hover'); + form.find(".div-dropzone-hover").css("opacity", 0.7); + }, + dragleave: function() { + $mdArea.removeClass('is-dropzone-hover'); + form.find(".div-dropzone-hover").css("opacity", 0); + }, + drop: function() { + $mdArea.removeClass('is-dropzone-hover'); + form.find(".div-dropzone-hover").css("opacity", 0); + form_textarea.focus(); + }, + success: function(header, response) { + pasteText(response.link.markdown); + }, + error: function(temp) { + var checkIfMsgExists, errorAlert; + errorAlert = $(form).find('.error-alert'); + checkIfMsgExists = errorAlert.children().length; + if (checkIfMsgExists === 0) { + errorAlert.append(divAlert); + $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed."); + } + }, + totaluploadprogress: function(totalUploadProgress) { + uploadProgress.text(Math.round(totalUploadProgress) + "%"); + }, + sending: function() { + form_dropzone.find(".div-dropzone-spinner").css({ + "opacity": 0.7, + "display": "inherit" + }); + }, + queuecomplete: function() { + uploadProgress.text(""); + $(".dz-preview").remove(); + $(".markdown-area").trigger("input"); + $(".div-dropzone-spinner").css({ + "opacity": 0, + "display": "none" + }); + } + }); + child = $(dropzone[0]).children("textarea"); + handlePaste = function(event) { + var filename, image, pasteEvent, text; + pasteEvent = event.originalEvent; + if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) { + image = isImage(pasteEvent); + if (image) { + event.preventDefault(); + filename = getFilename(pasteEvent) || "image.png"; + text = "{{" + filename + "}}"; + pasteText(text); + return uploadFile(image.getAsFile(), filename); + } + } + }; + isImage = function(data) { + var i, item; + i = 0; + while (i < data.clipboardData.items.length) { + item = data.clipboardData.items[i]; + if (item.type.indexOf("image") !== -1) { + return item; + } + i++; + } + return false; + }; + pasteText = function(text) { + var afterSelection, beforeSelection, caretEnd, caretStart, textEnd; + caretStart = $(child)[0].selectionStart; + caretEnd = $(child)[0].selectionEnd; + textEnd = $(child).val().length; + beforeSelection = $(child).val().substring(0, caretStart); + afterSelection = $(child).val().substring(caretEnd, textEnd); + $(child).val(beforeSelection + text + afterSelection); + child.get(0).setSelectionRange(caretStart + text.length, caretEnd + text.length); + return form_textarea.trigger("input"); + }; + getFilename = function(e) { + var value; + if (window.clipboardData && window.clipboardData.getData) { + value = window.clipboardData.getData("Text"); + } else if (e.clipboardData && e.clipboardData.getData) { + value = e.clipboardData.getData("text/plain"); + } + value = value.split("\r"); + return value.first(); + }; + uploadFile = function(item, filename) { + var formData; + formData = new FormData(); + formData.append("file", item, filename); + return $.ajax({ + url: project_uploads_path, + type: "POST", + data: formData, + dataType: "json", + processData: false, + contentType: false, + headers: { + "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + }, + beforeSend: function() { + showSpinner(); + return closeAlertMessage(); + }, + success: function(e, textStatus, response) { + return insertToTextArea(filename, response.responseJSON.link.markdown); + }, + error: function(response) { + return showError(response.responseJSON.message); + }, + complete: function() { + return closeSpinner(); + } + }); + }; + insertToTextArea = function(filename, url) { + return $(child).val(function(index, val) { + return val.replace("{{" + filename + "}}", url + "\n"); + }); + }; + appendToTextArea = function(url) { + return $(child).val(function(index, val) { + return val + url + "\n"; + }); + }; + showSpinner = function(e) { + return form.find(".div-dropzone-spinner").css({ + "opacity": 0.7, + "display": "inherit" + }); + }; + closeSpinner = function() { + return form.find(".div-dropzone-spinner").css({ + "opacity": 0, + "display": "none" + }); + }; + showError = function(message) { + var checkIfMsgExists, errorAlert; + errorAlert = $(form).find('.error-alert'); + checkIfMsgExists = errorAlert.children().length; + if (checkIfMsgExists === 0) { + errorAlert.append(divAlert); + return $(".div-dropzone-alert").append(btnAlert + message); + } + }; + closeAlertMessage = function() { + return form.find(".div-dropzone-alert").alert("close"); + }; + form.find(".markdown-selector").click(function(e) { + e.preventDefault(); + $(this).closest('.gfm-form').find('.div-dropzone').click(); + }); + } + + return DropzoneInput; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/dropzone_input.js.coffee b/app/assets/javascripts/dropzone_input.js.coffee deleted file mode 100644 index 665246e2a7d..00000000000 --- a/app/assets/javascripts/dropzone_input.js.coffee +++ /dev/null @@ -1,201 +0,0 @@ -#= require markdown_preview - -class @DropzoneInput - constructor: (form) -> - Dropzone.autoDiscover = false - alertClass = "alert alert-danger alert-dismissable div-dropzone-alert" - alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\"" - divHover = "<div class=\"div-dropzone-hover\"></div>" - divSpinner = "<div class=\"div-dropzone-spinner\"></div>" - divAlert = "<div class=\"" + alertClass + "\"></div>" - iconPaperclip = "<i class=\"fa fa-paperclip div-dropzone-icon\"></i>" - 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 or null - max_file_size = gon.max_file_size or 10 - - form_textarea = $(form).find(".js-gfm-input") - form_textarea.wrap "<div class=\"div-dropzone\"></div>" - form_textarea.on 'paste', (event) => - handlePaste(event) - - $mdArea = $(form_textarea).closest('.md-area') - - $(form).setupMarkdownPreview() - - form_dropzone = $(form).find('.div-dropzone') - form_dropzone.parent().addClass "div-dropzone-wrapper" - form_dropzone.append divHover - form_dropzone.find(".div-dropzone-hover").append iconPaperclip - form_dropzone.append divSpinner - form_dropzone.find(".div-dropzone-spinner").append iconSpinner - form_dropzone.find(".div-dropzone-spinner").append uploadProgress - form_dropzone.find(".div-dropzone-spinner").css - "opacity": 0 - "display": "none" - - dropzone = form_dropzone.dropzone( - url: project_uploads_path - dictDefaultMessage: "" - clickable: true - paramName: "file" - maxFilesize: max_file_size - uploadMultiple: false - headers: - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") - - previewContainer: false - - processing: -> - $(".div-dropzone-alert").alert "close" - - dragover: -> - $mdArea.addClass 'is-dropzone-hover' - form.find(".div-dropzone-hover").css "opacity", 0.7 - return - - dragleave: -> - $mdArea.removeClass 'is-dropzone-hover' - form.find(".div-dropzone-hover").css "opacity", 0 - return - - drop: -> - $mdArea.removeClass 'is-dropzone-hover' - form.find(".div-dropzone-hover").css "opacity", 0 - form_textarea.focus() - return - - success: (header, response) -> - pasteText response.link.markdown - return - - error: (temp) -> - errorAlert = $(form).find('.error-alert') - checkIfMsgExists = errorAlert.children().length - if checkIfMsgExists is 0 - errorAlert.append divAlert - $(".div-dropzone-alert").append "#{btnAlert}Attaching the file failed." - return - - totaluploadprogress: (totalUploadProgress) -> - uploadProgress.text Math.round(totalUploadProgress) + "%" - return - - sending: -> - form_dropzone.find(".div-dropzone-spinner").css - "opacity": 0.7 - "display": "inherit" - return - - queuecomplete: -> - uploadProgress.text "" - $(".dz-preview").remove() - $(".markdown-area").trigger "input" - $(".div-dropzone-spinner").css - "opacity": 0 - "display": "none" - return - ) - - child = $(dropzone[0]).children("textarea") - - handlePaste = (event) -> - pasteEvent = event.originalEvent - if pasteEvent.clipboardData and pasteEvent.clipboardData.items - image = isImage(pasteEvent) - if image - event.preventDefault() - - filename = getFilename(pasteEvent) or "image.png" - text = "{{" + filename + "}}" - pasteText(text) - uploadFile image.getAsFile(), filename - - isImage = (data) -> - i = 0 - while i < data.clipboardData.items.length - item = data.clipboardData.items[i] - if item.type.indexOf("image") isnt -1 - return item - i++ - return false - - pasteText = (text) -> - caretStart = $(child)[0].selectionStart - caretEnd = $(child)[0].selectionEnd - textEnd = $(child).val().length - - beforeSelection = $(child).val().substring 0, caretStart - afterSelection = $(child).val().substring caretEnd, textEnd - $(child).val beforeSelection + text + afterSelection - child.get(0).setSelectionRange caretStart + text.length, caretEnd + text.length - form_textarea.trigger "input" - - getFilename = (e) -> - if window.clipboardData and window.clipboardData.getData - value = window.clipboardData.getData("Text") - else if e.clipboardData and e.clipboardData.getData - value = e.clipboardData.getData("text/plain") - - value = value.split("\r") - value.first() - - uploadFile = (item, filename) -> - formData = new FormData() - formData.append "file", item, filename - $.ajax - url: project_uploads_path - type: "POST" - data: formData - dataType: "json" - processData: false - contentType: false - headers: - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") - - beforeSend: -> - showSpinner() - closeAlertMessage() - - success: (e, textStatus, response) -> - insertToTextArea(filename, response.responseJSON.link.markdown) - - error: (response) -> - showError(response.responseJSON.message) - - complete: -> - closeSpinner() - - insertToTextArea = (filename, url) -> - $(child).val (index, val) -> - val.replace("{{" + filename + "}}", url + "\n") - - appendToTextArea = (url) -> - $(child).val (index, val) -> - val + url + "\n" - - showSpinner = (e) -> - form.find(".div-dropzone-spinner").css - "opacity": 0.7 - "display": "inherit" - - closeSpinner = -> - form.find(".div-dropzone-spinner").css - "opacity": 0 - "display": "none" - - showError = (message) -> - errorAlert = $(form).find('.error-alert') - checkIfMsgExists = errorAlert.children().length - if checkIfMsgExists is 0 - errorAlert.append divAlert - $(".div-dropzone-alert").append btnAlert + message - - closeAlertMessage = -> - form.find(".div-dropzone-alert").alert "close" - - form.find(".markdown-selector").click (e) -> - e.preventDefault() - $(@).closest('.gfm-form').find('.div-dropzone').click() - return diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js new file mode 100644 index 00000000000..5a725a41fd1 --- /dev/null +++ b/app/assets/javascripts/due_date_select.js @@ -0,0 +1,104 @@ +(function() { + this.DueDateSelect = (function() { + function DueDateSelect() { + var $datePicker, $dueDate, $loading; + $datePicker = $('.datepicker'); + if ($datePicker.length) { + $dueDate = $('#milestone_due_date'); + $datePicker.datepicker({ + dateFormat: 'yy-mm-dd', + onSelect: function(dateText, inst) { + return $dueDate.val(dateText); + } + }).datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val())); + } + $('.js-clear-due-date').on('click', function(e) { + e.preventDefault(); + return $.datepicker._clearDate($datePicker); + }); + $loading = $('.js-issuable-update .due_date').find('.block-loading').hide(); + $('.js-due-date-select').each(function(i, dropdown) { + var $block, $dropdown, $dropdownParent, $selectbox, $sidebarValue, $value, $valueContent, abilityName, addDueDate, fieldName, issueUpdateURL; + $dropdown = $(dropdown); + $dropdownParent = $dropdown.closest('.dropdown'); + $datePicker = $dropdownParent.find('.js-due-date-calendar'); + $block = $dropdown.closest('.block'); + $selectbox = $dropdown.closest('.selectbox'); + $value = $block.find('.value'); + $valueContent = $block.find('.value-content'); + $sidebarValue = $('.js-due-date-sidebar-value', $block); + fieldName = $dropdown.data('field-name'); + abilityName = $dropdown.data('ability-name'); + issueUpdateURL = $dropdown.data('issue-update'); + $dropdown.glDropdown({ + hidden: function() { + $selectbox.hide(); + return $value.css('display', ''); + } + }); + addDueDate = function(isDropdown) { + var data, date, mediumDate, value; + value = $("input[name='" + fieldName + "']").val(); + if (value !== '') { + date = new Date(value.replace(new RegExp('-', 'g'), ',')); + mediumDate = $.datepicker.formatDate('M d, yy', date); + } else { + mediumDate = 'No due date'; + } + data = {}; + data[abilityName] = {}; + data[abilityName].due_date = value; + return $.ajax({ + type: 'PUT', + url: issueUpdateURL, + data: data, + dataType: 'json', + beforeSend: function() { + var cssClass; + $loading.fadeIn(); + if (isDropdown) { + $dropdown.trigger('loading.gl.dropdown'); + $selectbox.hide(); + } + $value.css('display', ''); + cssClass = Date.parse(mediumDate) ? 'bold' : 'no-value'; + $valueContent.html("<span class='" + cssClass + "'>" + mediumDate + "</span>"); + $sidebarValue.html(mediumDate); + if (value !== '') { + return $('.js-remove-due-date-holder').removeClass('hidden'); + } else { + return $('.js-remove-due-date-holder').addClass('hidden'); + } + } + }).done(function(data) { + if (isDropdown) { + $dropdown.trigger('loaded.gl.dropdown'); + $dropdown.dropdown('toggle'); + } + return $loading.fadeOut(); + }); + }; + $block.on('click', '.js-remove-due-date', function(e) { + e.preventDefault(); + $("input[name='" + fieldName + "']").val(''); + return addDueDate(false); + }); + return $datePicker.datepicker({ + dateFormat: 'yy-mm-dd', + defaultDate: $("input[name='" + fieldName + "']").val(), + altField: "input[name='" + fieldName + "']", + onSelect: function() { + return addDueDate(true); + } + }); + }); + $(document).off('click', '.ui-datepicker-header a').on('click', '.ui-datepicker-header a', function(e) { + return e.stopImmediatePropagation(); + }); + } + + return DueDateSelect; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee deleted file mode 100644 index d65c018dad5..00000000000 --- a/app/assets/javascripts/due_date_select.js.coffee +++ /dev/null @@ -1,99 +0,0 @@ -class @DueDateSelect - constructor: -> - # Milestone edit/new form - $datePicker = $('.datepicker') - - if $datePicker.length - $dueDate = $('#milestone_due_date') - $datePicker.datepicker - dateFormat: 'yy-mm-dd' - onSelect: (dateText, inst) -> - $dueDate.val(dateText) - .datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val())) - - $('.js-clear-due-date').on 'click', (e) -> - e.preventDefault() - $.datepicker._clearDate($datePicker) - - # Issuable sidebar - $loading = $('.js-issuable-update .due_date') - .find('.block-loading') - .hide() - - $('.js-due-date-select').each (i, dropdown) -> - $dropdown = $(dropdown) - $dropdownParent = $dropdown.closest('.dropdown') - $datePicker = $dropdownParent.find('.js-due-date-calendar') - $block = $dropdown.closest('.block') - $selectbox = $dropdown.closest('.selectbox') - $value = $block.find('.value') - $valueContent = $block.find('.value-content') - $sidebarValue = $('.js-due-date-sidebar-value', $block) - - fieldName = $dropdown.data('field-name') - abilityName = $dropdown.data('ability-name') - issueUpdateURL = $dropdown.data('issue-update') - - $dropdown.glDropdown( - hidden: -> - $selectbox.hide() - $value.css('display', '') - ) - - addDueDate = (isDropdown) -> - # Create the post date - value = $("input[name='#{fieldName}']").val() - - if value isnt '' - date = new Date value.replace(new RegExp('-', 'g'), ',') - mediumDate = $.datepicker.formatDate 'M d, yy', date - else - mediumDate = 'No due date' - - data = {} - data[abilityName] = {} - data[abilityName].due_date = value - - $.ajax( - type: 'PUT' - url: issueUpdateURL - data: data - dataType: 'json' - beforeSend: -> - $loading.fadeIn() - if isDropdown - $dropdown.trigger('loading.gl.dropdown') - $selectbox.hide() - $value.css('display', '') - - cssClass = if Date.parse(mediumDate) then 'bold' else 'no-value' - $valueContent.html("<span class='#{cssClass}'>#{mediumDate}</span>") - $sidebarValue.html(mediumDate) - - if value isnt '' - $('.js-remove-due-date-holder').removeClass 'hidden' - else - $('.js-remove-due-date-holder').addClass 'hidden' - ).done (data) -> - if isDropdown - $dropdown.trigger('loaded.gl.dropdown') - $dropdown.dropdown('toggle') - $loading.fadeOut() - - $block.on 'click', '.js-remove-due-date', (e) -> - e.preventDefault() - $("input[name='#{fieldName}']").val '' - addDueDate(false) - - $datePicker.datepicker( - dateFormat: 'yy-mm-dd', - defaultDate: $("input[name='#{fieldName}']").val() - altField: "input[name='#{fieldName}']" - onSelect: -> - addDueDate(true) - ) - - $(document) - .off 'click', '.ui-datepicker-header a' - .on 'click', '.ui-datepicker-header a', (e) -> - e.stopImmediatePropagation() diff --git a/app/assets/javascripts/extensions/jquery.js b/app/assets/javascripts/extensions/jquery.js new file mode 100644 index 00000000000..ae3dde63da3 --- /dev/null +++ b/app/assets/javascripts/extensions/jquery.js @@ -0,0 +1,14 @@ +(function() { + $.fn.extend({ + disable: function() { + return $(this).attr('disabled', 'disabled').addClass('disabled'); + } + }); + + $.fn.extend({ + enable: function() { + return $(this).removeAttr('disabled').removeClass('disabled'); + } + }); + +}).call(this); diff --git a/app/assets/javascripts/extensions/jquery.js.coffee b/app/assets/javascripts/extensions/jquery.js.coffee deleted file mode 100644 index 0a9db8eb5ef..00000000000 --- a/app/assets/javascripts/extensions/jquery.js.coffee +++ /dev/null @@ -1,11 +0,0 @@ -# Disable an element and add the 'disabled' Bootstrap class -$.fn.extend disable: -> - $(@) - .attr('disabled', 'disabled') - .addClass('disabled') - -# Enable an element and remove the 'disabled' Bootstrap class -$.fn.extend enable: -> - $(@) - .removeAttr('disabled') - .removeClass('disabled') diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js new file mode 100644 index 00000000000..09b5eb398d4 --- /dev/null +++ b/app/assets/javascripts/files_comment_button.js @@ -0,0 +1,141 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.FilesCommentButton = (function() { + var COMMENT_BUTTON_CLASS, COMMENT_BUTTON_TEMPLATE, DEBOUNCE_TIMEOUT_DURATION, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; + + COMMENT_BUTTON_CLASS = '.add-diff-note'; + + COMMENT_BUTTON_TEMPLATE = _.template('<button name="button" type="submit" class="btn <%- COMMENT_BUTTON_CLASS %> js-add-diff-note-button" title="Add a comment to this line"><i class="fa fa-comment-o"></i></button>'); + + LINE_HOLDER_CLASS = '.line_holder'; + + LINE_NUMBER_CLASS = 'diff-line-num'; + + LINE_CONTENT_CLASS = 'line_content'; + + UNFOLDABLE_LINE_CLASS = 'js-unfold'; + + EMPTY_CELL_CLASS = 'empty-cell'; + + OLD_LINE_CLASS = 'old_line'; + + LINE_COLUMN_CLASSES = "." + LINE_NUMBER_CLASS + ", .line_content"; + + TEXT_FILE_SELECTOR = '.text-file'; + + DEBOUNCE_TIMEOUT_DURATION = 100; + + function FilesCommentButton(filesContainerElement) { + var debounce; + this.filesContainerElement = filesContainerElement; + this.destroy = bind(this.destroy, this); + this.render = bind(this.render, this); + this.VIEW_TYPE = $('input#view[type=hidden]').val(); + debounce = _.debounce(this.render, DEBOUNCE_TIMEOUT_DURATION); + $(document).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy); + } + + FilesCommentButton.prototype.render = function(e) { + var $currentTarget, buttonParentElement, lineContentElement, textFileElement; + $currentTarget = $(e.currentTarget); + buttonParentElement = this.getButtonParent($currentTarget); + if (!this.shouldRender(e, buttonParentElement)) { + return; + } + textFileElement = this.getTextFileElement($currentTarget); + lineContentElement = this.getLineContent($currentTarget); + buttonParentElement.append(this.buildButton({ + noteableType: textFileElement.attr('data-noteable-type'), + noteableID: textFileElement.attr('data-noteable-id'), + commitID: textFileElement.attr('data-commit-id'), + noteType: lineContentElement.attr('data-note-type'), + position: lineContentElement.attr('data-position'), + lineType: lineContentElement.attr('data-line-type'), + discussionID: lineContentElement.attr('data-discussion-id'), + lineCode: lineContentElement.attr('data-line-code') + })); + }; + + FilesCommentButton.prototype.destroy = function(e) { + if (this.isMovingToSameType(e)) { + return; + } + $(COMMENT_BUTTON_CLASS, this.getButtonParent($(e.currentTarget))).remove(); + }; + + FilesCommentButton.prototype.buildButton = function(buttonAttributes) { + var initializedButtonTemplate; + initializedButtonTemplate = COMMENT_BUTTON_TEMPLATE({ + COMMENT_BUTTON_CLASS: COMMENT_BUTTON_CLASS.substr(1) + }); + return $(initializedButtonTemplate).attr({ + 'data-noteable-type': buttonAttributes.noteableType, + 'data-noteable-id': buttonAttributes.noteableID, + 'data-commit-id': buttonAttributes.commitID, + 'data-note-type': buttonAttributes.noteType, + 'data-line-code': buttonAttributes.lineCode, + 'data-position': buttonAttributes.position, + 'data-discussion-id': buttonAttributes.discussionID, + 'data-line-type': buttonAttributes.lineType + }); + }; + + FilesCommentButton.prototype.getTextFileElement = function(hoveredElement) { + return $(hoveredElement.closest(TEXT_FILE_SELECTOR)); + }; + + FilesCommentButton.prototype.getLineContent = function(hoveredElement) { + if (hoveredElement.hasClass(LINE_CONTENT_CLASS)) { + return hoveredElement; + } + if (this.VIEW_TYPE === 'inline') { + return $(hoveredElement).closest(LINE_HOLDER_CLASS).find("." + LINE_CONTENT_CLASS); + } else { + return $(hoveredElement).next("." + LINE_CONTENT_CLASS); + } + }; + + FilesCommentButton.prototype.getButtonParent = function(hoveredElement) { + if (this.VIEW_TYPE === 'inline') { + if (hoveredElement.hasClass(OLD_LINE_CLASS)) { + return hoveredElement; + } + return hoveredElement.parent().find("." + OLD_LINE_CLASS); + } else { + if (hoveredElement.hasClass(LINE_NUMBER_CLASS)) { + return hoveredElement; + } + return $(hoveredElement).prev("." + LINE_NUMBER_CLASS); + } + }; + + FilesCommentButton.prototype.isMovingToSameType = function(e) { + var newButtonParent; + newButtonParent = this.getButtonParent($(e.toElement)); + if (!newButtonParent) { + return false; + } + return newButtonParent.is(this.getButtonParent($(e.currentTarget))); + }; + + FilesCommentButton.prototype.shouldRender = function(e, buttonParentElement) { + return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS) && $(COMMENT_BUTTON_CLASS, buttonParentElement).length === 0; + }; + + return FilesCommentButton; + + })(); + + $.fn.filesCommentButton = function() { + if (!(this && (this.parent().data('can-create-note') != null))) { + return; + } + return this.each(function() { + if (!$.data(this, 'filesCommentButton')) { + return $.data(this, 'filesCommentButton', new FilesCommentButton($(this))); + } + }); + }; + +}).call(this); diff --git a/app/assets/javascripts/files_comment_button.js.coffee b/app/assets/javascripts/files_comment_button.js.coffee deleted file mode 100644 index 5ab82c39fcd..00000000000 --- a/app/assets/javascripts/files_comment_button.js.coffee +++ /dev/null @@ -1,98 +0,0 @@ -class @FilesCommentButton - COMMENT_BUTTON_CLASS = '.add-diff-note' - COMMENT_BUTTON_TEMPLATE = _.template '<button name="button" type="submit" class="btn <%- COMMENT_BUTTON_CLASS %> js-add-diff-note-button" title="Add a comment to this line"><i class="fa fa-comment-o"></i></button>' - LINE_HOLDER_CLASS = '.line_holder' - LINE_NUMBER_CLASS = 'diff-line-num' - LINE_CONTENT_CLASS = 'line_content' - UNFOLDABLE_LINE_CLASS = 'js-unfold' - EMPTY_CELL_CLASS = 'empty-cell' - OLD_LINE_CLASS = 'old_line' - LINE_COLUMN_CLASSES = ".#{LINE_NUMBER_CLASS}, .line_content" - TEXT_FILE_SELECTOR = '.text-file' - DEBOUNCE_TIMEOUT_DURATION = 100 - - constructor: (@filesContainerElement) -> - @VIEW_TYPE = $('input#view[type=hidden]').val() - - debounce = _.debounce @render, DEBOUNCE_TIMEOUT_DURATION - - $(document) - .off 'mouseover', LINE_COLUMN_CLASSES - .off 'mouseleave', LINE_COLUMN_CLASSES - .on 'mouseover', LINE_COLUMN_CLASSES, debounce - .on 'mouseleave', LINE_COLUMN_CLASSES, @destroy - - render: (e) => - $currentTarget = $(e.currentTarget) - buttonParentElement = @getButtonParent $currentTarget - return unless @shouldRender e, buttonParentElement - - textFileElement = @getTextFileElement $currentTarget - lineContentElement = @getLineContent $currentTarget - - buttonParentElement.append @buildButton - noteableType: textFileElement.attr 'data-noteable-type' - noteableID: textFileElement.attr 'data-noteable-id' - commitID: textFileElement.attr 'data-commit-id' - noteType: lineContentElement.attr 'data-note-type' - position: lineContentElement.attr 'data-position' - lineType: lineContentElement.attr 'data-line-type' - discussionID: lineContentElement.attr 'data-discussion-id' - lineCode: lineContentElement.attr 'data-line-code' - return - - destroy: (e) => - return if @isMovingToSameType e - $(COMMENT_BUTTON_CLASS, @getButtonParent $(e.currentTarget)).remove() - return - - buildButton: (buttonAttributes) -> - initializedButtonTemplate = COMMENT_BUTTON_TEMPLATE - COMMENT_BUTTON_CLASS: COMMENT_BUTTON_CLASS.substr 1 - $(initializedButtonTemplate).attr - 'data-noteable-type': buttonAttributes.noteableType - 'data-noteable-id': buttonAttributes.noteableID - 'data-commit-id': buttonAttributes.commitID - 'data-note-type': buttonAttributes.noteType - 'data-line-code': buttonAttributes.lineCode - 'data-position': buttonAttributes.position - 'data-discussion-id': buttonAttributes.discussionID - 'data-line-type': buttonAttributes.lineType - - getTextFileElement: (hoveredElement) -> - $(hoveredElement.closest TEXT_FILE_SELECTOR) - - getLineContent: (hoveredElement) -> - return hoveredElement if hoveredElement.hasClass LINE_CONTENT_CLASS - - if @VIEW_TYPE is 'inline' - return $(hoveredElement).closest(LINE_HOLDER_CLASS).find ".#{LINE_CONTENT_CLASS}" - else - return $(hoveredElement).next ".#{LINE_CONTENT_CLASS}" - - getButtonParent: (hoveredElement) -> - if @VIEW_TYPE is 'inline' - return hoveredElement if hoveredElement.hasClass OLD_LINE_CLASS - - hoveredElement.parent().find ".#{OLD_LINE_CLASS}" - else - return hoveredElement if hoveredElement.hasClass LINE_NUMBER_CLASS - - $(hoveredElement).prev ".#{LINE_NUMBER_CLASS}" - - isMovingToSameType: (e) -> - newButtonParent = @getButtonParent $(e.toElement) - return false unless newButtonParent - newButtonParent.is @getButtonParent $(e.currentTarget) - - shouldRender: (e, buttonParentElement) -> - (not buttonParentElement.hasClass(EMPTY_CELL_CLASS) and \ - not buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS) and \ - $(COMMENT_BUTTON_CLASS, buttonParentElement).length is 0) - -$.fn.filesCommentButton = -> - return unless this and @parent().data('can-create-note')? - - @each -> - unless $.data this, 'filesCommentButton' - $.data this, 'filesCommentButton', new FilesCommentButton $(this) diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js new file mode 100644 index 00000000000..c8a02d6fa15 --- /dev/null +++ b/app/assets/javascripts/flash.js @@ -0,0 +1,43 @@ +(function() { + this.Flash = (function() { + var hideFlash; + + hideFlash = function() { + return $(this).fadeOut(); + }; + + function Flash(message, type, parent) { + var flash, textDiv; + if (type == null) { + type = 'alert'; + } + if (parent == null) { + parent = null; + } + if (parent) { + this.flashContainer = parent.find('.flash-container'); + } else { + this.flashContainer = $('.flash-container-page'); + } + this.flashContainer.html(''); + flash = $('<div/>', { + "class": "flash-" + type + }); + flash.on('click', hideFlash); + textDiv = $('<div/>', { + "class": 'flash-text', + text: message + }); + textDiv.appendTo(flash); + if (this.flashContainer.parent().hasClass('content-wrapper')) { + textDiv.addClass('container-fluid container-limited'); + } + flash.appendTo(this.flashContainer); + this.flashContainer.show(); + } + + return Flash; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/flash.js.coffee b/app/assets/javascripts/flash.js.coffee deleted file mode 100644 index 5a493041538..00000000000 --- a/app/assets/javascripts/flash.js.coffee +++ /dev/null @@ -1,28 +0,0 @@ -class @Flash - hideFlash = -> $(@).fadeOut() - - constructor: (message, type = 'alert', parent = null)-> - if parent - @flashContainer = parent.find('.flash-container') - else - @flashContainer = $('.flash-container-page') - - @flashContainer.html('') - - flash = $('<div/>', - class: "flash-#{type}" - ) - flash.on 'click', hideFlash - - textDiv = $('<div/>', - class: 'flash-text', - text: message - ) - textDiv.appendTo(flash) - - if @flashContainer.parent().hasClass('content-wrapper') - textDiv.addClass('container-fluid container-limited') - - flash.appendTo(@flashContainer) - @flashContainer.show() - diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js new file mode 100644 index 00000000000..2e5b15f4b77 --- /dev/null +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -0,0 +1,272 @@ +(function() { + if (window.GitLab == null) { + window.GitLab = {}; + } + + GitLab.GfmAutoComplete = { + dataLoading: false, + dataLoaded: false, + cachedData: {}, + dataSource: '', + Emoji: { + template: '<li>${name} <img alt="${name}" height="20" src="${path}" width="20" /></li>' + }, + Members: { + template: '<li>${username} <small>${title}</small></li>' + }, + Labels: { + template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>' + }, + Issues: { + template: '<li><small>${id}</small> ${title}</li>' + }, + Milestones: { + template: '<li>${title}</li>' + }, + Loading: { + template: '<li><i class="fa fa-refresh fa-spin"></i> Loading...</li>' + }, + DefaultOptions: { + sorter: function(query, items, searchKey) { + if ((items[0].name != null) && items[0].name === 'loading') { + return items; + } + return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey); + }, + filter: function(query, data, searchKey) { + if (data[0] === 'loading') { + return data; + } + return $.fn.atwho["default"].callbacks.filter(query, data, searchKey); + }, + beforeInsert: function(value) { + if (!GitLab.GfmAutoComplete.dataLoaded) { + return this.at; + } else { + return value; + } + } + }, + setup: function(input) { + this.input = input || $('.js-gfm-input'); + this.destroyAtWho(); + this.setupAtWho(); + if (this.dataSource) { + if (!this.dataLoading && !this.cachedData) { + this.dataLoading = true; + setTimeout((function(_this) { + return function() { + var fetch; + fetch = _this.fetchData(_this.dataSource); + return fetch.done(function(data) { + _this.dataLoading = false; + return _this.loadData(data); + }); + }; + })(this), 1000); + } + if (this.cachedData != null) { + return this.loadData(this.cachedData); + } + } + }, + setupAtWho: function() { + this.input.atwho({ + at: ':', + displayTpl: (function(_this) { + return function(value) { + if (value.path != null) { + return _this.Emoji.template; + } else { + return _this.Loading.template; + } + }; + })(this), + insertTpl: ':${name}:', + data: ['loading'], + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert + } + }); + this.input.atwho({ + at: '@', + displayTpl: (function(_this) { + return function(value) { + if (value.username != null) { + return _this.Members.template; + } else { + return _this.Loading.template; + } + }; + })(this), + insertTpl: '${atwho-at}${username}', + searchKey: 'search', + data: ['loading'], + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(members) { + return $.map(members, function(m) { + var title; + if (m.username == null) { + return m; + } + title = m.name; + if (m.count) { + title += " (" + m.count + ")"; + } + return { + username: m.username, + title: sanitize(title), + search: sanitize(m.username + " " + m.name) + }; + }); + } + } + }); + this.input.atwho({ + at: '#', + alias: 'issues', + searchKey: 'search', + displayTpl: (function(_this) { + return function(value) { + if (value.title != null) { + return _this.Issues.template; + } else { + return _this.Loading.template; + } + }; + })(this), + data: ['loading'], + insertTpl: '${atwho-at}${id}', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(issues) { + return $.map(issues, function(i) { + if (i.title == null) { + return i; + } + return { + id: i.iid, + title: sanitize(i.title), + search: i.iid + " " + i.title + }; + }); + } + } + }); + this.input.atwho({ + at: '%', + alias: 'milestones', + searchKey: 'search', + displayTpl: (function(_this) { + return function(value) { + if (value.title != null) { + return _this.Milestones.template; + } else { + return _this.Loading.template; + } + }; + })(this), + insertTpl: '${atwho-at}"${title}"', + data: ['loading'], + callbacks: { + beforeSave: function(milestones) { + return $.map(milestones, function(m) { + if (m.title == null) { + return m; + } + return { + id: m.iid, + title: sanitize(m.title), + search: "" + m.title + }; + }); + } + } + }); + this.input.atwho({ + at: '!', + alias: 'mergerequests', + searchKey: 'search', + displayTpl: (function(_this) { + return function(value) { + if (value.title != null) { + return _this.Issues.template; + } else { + return _this.Loading.template; + } + }; + })(this), + data: ['loading'], + insertTpl: '${atwho-at}${id}', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(merges) { + return $.map(merges, function(m) { + if (m.title == null) { + return m; + } + return { + id: m.iid, + title: sanitize(m.title), + search: m.iid + " " + m.title + }; + }); + } + } + }); + return this.input.atwho({ + at: '~', + alias: 'labels', + searchKey: 'search', + displayTpl: this.Labels.template, + insertTpl: '${atwho-at}${title}', + callbacks: { + beforeSave: function(merges) { + var sanitizeLabelTitle; + sanitizeLabelTitle = function(title) { + if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) { + return "\"" + (sanitize(title)) + "\""; + } else { + return sanitize(title); + } + }; + return $.map(merges, function(m) { + return { + title: sanitizeLabelTitle(m.title), + color: m.color, + search: "" + m.title + }; + }); + } + } + }); + }, + destroyAtWho: function() { + return this.input.atwho('destroy'); + }, + fetchData: function(dataSource) { + return $.getJSON(dataSource); + }, + loadData: function(data) { + this.cachedData = data; + this.dataLoaded = true; + this.input.atwho('load', '@', data.members); + this.input.atwho('load', 'issues', data.issues); + this.input.atwho('load', 'milestones', data.milestones); + this.input.atwho('load', 'mergerequests', data.mergerequests); + this.input.atwho('load', ':', data.emojis); + this.input.atwho('load', '~', data.labels); + return $(':focus').trigger('keyup'); + } + }; + +}).call(this); diff --git a/app/assets/javascripts/gfm_auto_complete.js.coffee b/app/assets/javascripts/gfm_auto_complete.js.coffee deleted file mode 100644 index 4a851d9c9fb..00000000000 --- a/app/assets/javascripts/gfm_auto_complete.js.coffee +++ /dev/null @@ -1,228 +0,0 @@ -# Creates the variables for setting up GFM auto-completion - -window.GitLab ?= {} -GitLab.GfmAutoComplete = - dataLoading: false - dataLoaded: false - cachedData: {} - dataSource: '' - - # Emoji - Emoji: - template: '<li>${name} <img alt="${name}" height="20" src="${path}" width="20" /></li>' - - # Team Members - Members: - template: '<li>${username} <small>${title}</small></li>' - - Labels: - template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>' - - # Issues and MergeRequests - Issues: - template: '<li><small>${id}</small> ${title}</li>' - - # Milestones - Milestones: - template: '<li>${title}</li>' - - Loading: - template: '<li><i class="fa fa-refresh fa-spin"></i> Loading...</li>' - - DefaultOptions: - sorter: (query, items, searchKey) -> - return items if items[0].name? and items[0].name is 'loading' - - $.fn.atwho.default.callbacks.sorter(query, items, searchKey) - filter: (query, data, searchKey) -> - return data if data[0] is 'loading' - - $.fn.atwho.default.callbacks.filter(query, data, searchKey) - beforeInsert: (value) -> - if not GitLab.GfmAutoComplete.dataLoaded - @at - else - value - - # Add GFM auto-completion to all input fields, that accept GFM input. - setup: (wrap) -> - @input = $('.js-gfm-input') - - # destroy previous instances - @destroyAtWho() - - # set up instances - @setupAtWho() - - if @dataSource - if not @dataLoading and not @cachedData - @dataLoading = true - - # We should wait until initializations are done - # and only trigger the last .setup since - # The previous .dataSource belongs to the previous issuable - # and the last one will have the **proper** .dataSource property - # TODO: Make this a singleton and turn off events when moving to another page - setTimeout( => - fetch = @fetchData(@dataSource) - fetch.done (data) => - @dataLoading = false - @loadData(data) - , 1000) - - if @cachedData? - @loadData(@cachedData) - - setupAtWho: -> - # Emoji - @input.atwho - at: ':' - displayTpl: (value) => - if value.path? - @Emoji.template - else - @Loading.template - insertTpl: ':${name}:' - data: ['loading'] - callbacks: - sorter: @DefaultOptions.sorter - filter: @DefaultOptions.filter - beforeInsert: @DefaultOptions.beforeInsert - - # Team Members - @input.atwho - at: '@' - displayTpl: (value) => - if value.username? - @Members.template - else - @Loading.template - insertTpl: '${atwho-at}${username}' - searchKey: 'search' - data: ['loading'] - callbacks: - sorter: @DefaultOptions.sorter - filter: @DefaultOptions.filter - beforeInsert: @DefaultOptions.beforeInsert - beforeSave: (members) -> - $.map members, (m) -> - return m if not m.username? - - title = m.name - title += " (#{m.count})" if m.count - - username: m.username - title: sanitize(title) - search: sanitize("#{m.username} #{m.name}") - - @input.atwho - at: '#' - alias: 'issues' - searchKey: 'search' - displayTpl: (value) => - if value.title? - @Issues.template - else - @Loading.template - data: ['loading'] - insertTpl: '${atwho-at}${id}' - callbacks: - sorter: @DefaultOptions.sorter - filter: @DefaultOptions.filter - beforeInsert: @DefaultOptions.beforeInsert - beforeSave: (issues) -> - $.map issues, (i) -> - return i if not i.title? - - id: i.iid - title: sanitize(i.title) - search: "#{i.iid} #{i.title}" - - @input.atwho - at: '%' - alias: 'milestones' - searchKey: 'search' - displayTpl: (value) => - if value.title? - @Milestones.template - else - @Loading.template - insertTpl: '${atwho-at}"${title}"' - data: ['loading'] - callbacks: - beforeSave: (milestones) -> - $.map milestones, (m) -> - return m if not m.title? - - id: m.iid - title: sanitize(m.title) - search: "#{m.title}" - - @input.atwho - at: '!' - alias: 'mergerequests' - searchKey: 'search' - displayTpl: (value) => - if value.title? - @Issues.template - else - @Loading.template - data: ['loading'] - insertTpl: '${atwho-at}${id}' - callbacks: - sorter: @DefaultOptions.sorter - filter: @DefaultOptions.filter - beforeInsert: @DefaultOptions.beforeInsert - beforeSave: (merges) -> - $.map merges, (m) -> - return m if not m.title? - - id: m.iid - title: sanitize(m.title) - search: "#{m.iid} #{m.title}" - - @input.atwho - at: '~' - alias: 'labels' - searchKey: 'search' - displayTpl: @Labels.template - insertTpl: '${atwho-at}${title}' - callbacks: - beforeSave: (merges) -> - sanitizeLabelTitle = (title)-> - if /[\w\?&]+\s+[\w\?&]+/g.test(title) - "\"#{sanitize(title)}\"" - else - sanitize(title) - - $.map merges, (m) -> - title: sanitizeLabelTitle(m.title) - color: m.color - search: "#{m.title}" - - destroyAtWho: -> - @input.atwho('destroy') - - fetchData: (dataSource) -> - $.getJSON(dataSource) - - loadData: (data) -> - @cachedData = data - @dataLoaded = true - - # load members - @input.atwho 'load', '@', data.members - # load issues - @input.atwho 'load', 'issues', data.issues - # load milestones - @input.atwho 'load', 'milestones', data.milestones - # load merge requests - @input.atwho 'load', 'mergerequests', data.mergerequests - # load emojis - @input.atwho 'load', ':', data.emojis - # load labels - @input.atwho 'load', '~', data.labels - - # This trigger at.js again - # otherwise we would be stuck with loading until the user types - $(':focus').trigger('keyup') diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js new file mode 100644 index 00000000000..d3394fae3f9 --- /dev/null +++ b/app/assets/javascripts/gl_dropdown.js @@ -0,0 +1,709 @@ +(function() { + var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, + bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, + indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; + + GitLabDropdownFilter = (function() { + var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; + + BLUR_KEYCODES = [27, 40]; + + ARROW_KEY_CODES = [38, 40]; + + HAS_VALUE_CLASS = "has-value"; + + function GitLabDropdownFilter(input, options) { + var $clearButton, $inputContainer, ref, timeout; + this.input = input; + this.options = options; + this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; + $inputContainer = this.input.parent(); + $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + this.indeterminateIds = []; + $clearButton.on('click', (function(_this) { + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.input.val('').trigger('keyup').focus(); + }; + })(this)); + timeout = ""; + this.input + .on('keydown', function (e) { + var keyCode = e.which; + + if (keyCode === 13) { + e.preventDefault() + } + }) + .on('keyup', function(e) { + var keyCode; + keyCode = e.which; + if (ARROW_KEY_CODES.indexOf(keyCode) >= 0) { + return; + } + if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.addClass(HAS_VALUE_CLASS); + } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.removeClass(HAS_VALUE_CLASS); + } + if (keyCode === 13) { + return false; + } + if (this.options.remote) { + clearTimeout(timeout); + return timeout = setTimeout(function() { + var blurField = this.shouldBlur(keyCode); + if (blurField && this.filterInputBlur) { + this.input.blur(); + } + return this.options.query(this.input.val(), function(data) { + return this.options.callback(data); + }.bind(this)); + }.bind(this), 250); + } else { + return this.filter(this.input.val()); + } + }.bind(this)); + } + + GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { + return BLUR_KEYCODES.indexOf(keyCode) >= 0; + }; + + GitLabDropdownFilter.prototype.filter = function(search_text) { + var data, elements, group, key, results, tmp; + if (this.options.onFilter) { + this.options.onFilter(search_text); + } + data = this.options.data(); + if ((data != null) && !this.options.filterByText) { + results = data; + if (search_text !== '') { + if (_.isArray(data)) { + results = fuzzaldrinPlus.filter(data, search_text, { + key: this.options.keys + }); + } else { + if (gl.utils.isObject(data)) { + results = {}; + for (key in data) { + group = data[key]; + tmp = fuzzaldrinPlus.filter(group, search_text, { + key: this.options.keys + }); + if (tmp.length) { + results[key] = tmp.map(function(item) { + return item; + }); + } + } + } + } + } + return this.options.callback(results); + } else { + elements = this.options.elements(); + if (search_text) { + return elements.each(function() { + var $el, matches; + $el = $(this); + matches = fuzzaldrinPlus.match($el.text().trim(), search_text); + if (!$el.is('.dropdown-header')) { + if (matches.length) { + return $el.show(); + } else { + return $el.hide(); + } + } + }); + } else { + return elements.show(); + } + } + }; + + return GitLabDropdownFilter; + + })(); + + GitLabDropdownRemote = (function() { + function GitLabDropdownRemote(dataEndpoint, options) { + this.dataEndpoint = dataEndpoint; + this.options = options; + } + + GitLabDropdownRemote.prototype.execute = function() { + if (typeof this.dataEndpoint === "string") { + return this.fetchData(); + } else if (typeof this.dataEndpoint === "function") { + if (this.options.beforeSend) { + this.options.beforeSend(); + } + return this.dataEndpoint("", (function(_this) { + return function(data) { + if (_this.options.success) { + _this.options.success(data); + } + if (_this.options.beforeSend) { + return _this.options.beforeSend(); + } + }; + })(this)); + } + }; + + GitLabDropdownRemote.prototype.fetchData = function() { + return $.ajax({ + url: this.dataEndpoint, + dataType: this.options.dataType, + beforeSend: (function(_this) { + return function() { + if (_this.options.beforeSend) { + return _this.options.beforeSend(); + } + }; + })(this), + success: (function(_this) { + return function(data) { + if (_this.options.success) { + return _this.options.success(data); + } + }; + })(this) + }); + }; + + return GitLabDropdownRemote; + + })(); + + GitLabDropdown = (function() { + var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, currentIndex; + + LOADING_CLASS = "is-loading"; + + PAGE_TWO_CLASS = "is-page-two"; + + ACTIVE_CLASS = "is-active"; + + INDETERMINATE_CLASS = "is-indeterminate"; + + currentIndex = -1; + + FILTER_INPUT = '.dropdown-input .dropdown-input-field'; + + function GitLabDropdown(el1, options) { + var ref, ref1, ref2, ref3, searchFields, selector, self; + this.el = el1; + this.options = options; + this.updateLabel = bind(this.updateLabel, this); + this.hidden = bind(this.hidden, this); + this.opened = bind(this.opened, this); + this.shouldPropagate = bind(this.shouldPropagate, this); + self = this; + selector = $(this.el).data("target"); + this.dropdown = selector != null ? $(selector) : $(this.el).parent(); + ref = this.options, this.filterInput = (ref1 = ref.filterInput) != null ? ref1 : this.getElement(FILTER_INPUT), this.highlight = (ref2 = ref.highlight) != null ? ref2 : false, this.filterInputBlur = (ref3 = ref.filterInputBlur) != null ? ref3 : true; + self = this; + if (_.isString(this.filterInput)) { + this.filterInput = this.getElement(this.filterInput); + } + searchFields = this.options.search ? this.options.search.fields : []; + if (this.options.data) { + if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { + this.fullData = this.options.data; + this.parseData(this.options.data); + } else { + this.remote = new GitLabDropdownRemote(this.options.data, { + dataType: this.options.dataType, + beforeSend: this.toggleLoading.bind(this), + success: (function(_this) { + return function(data) { + _this.fullData = data; + _this.parseData(_this.fullData); + if (_this.options.filterable && _this.filter && _this.filter.input) { + return _this.filter.input.trigger('keyup'); + } + }; + })(this) + }); + } + } + if (this.options.filterable) { + this.filter = new GitLabDropdownFilter(this.filterInput, { + filterInputBlur: this.filterInputBlur, + filterByText: this.options.filterByText, + onFilter: this.options.onFilter, + remote: this.options.filterRemote, + query: this.options.data, + keys: searchFields, + elements: (function(_this) { + return function() { + selector = '.dropdown-content li:not(.divider)'; + if (_this.dropdown.find('.dropdown-toggle-page').length) { + selector = ".dropdown-page-one " + selector; + } + return $(selector); + }; + })(this), + data: (function(_this) { + return function() { + return _this.fullData; + }; + })(this), + callback: (function(_this) { + return function(data) { + _this.parseData(data); + if (_this.filterInput.val() !== '') { + selector = '.dropdown-content li:not(.divider):visible'; + if (_this.dropdown.find('.dropdown-toggle-page').length) { + selector = ".dropdown-page-one " + selector; + } + $(selector, _this.dropdown).first().find('a').addClass('is-focused'); + return currentIndex = 0; + } + }; + })(this) + }); + } + this.dropdown.on("shown.bs.dropdown", this.opened); + this.dropdown.on("hidden.bs.dropdown", this.hidden); + $(this.el).on("update.label", this.updateLabel); + this.dropdown.on("click", ".dropdown-menu, .dropdown-menu-close", this.shouldPropagate); + this.dropdown.on('keyup', (function(_this) { + return function(e) { + if (e.which === 27) { + return $('.dropdown-menu-close', _this.dropdown).trigger('click'); + } + }; + })(this)); + this.dropdown.on('blur', 'a', (function(_this) { + return function(e) { + var $dropdownMenu, $relatedTarget; + if (e.relatedTarget != null) { + $relatedTarget = $(e.relatedTarget); + $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); + if ($dropdownMenu.length === 0) { + return _this.dropdown.removeClass('open'); + } + } + }; + })(this)); + if (this.dropdown.find(".dropdown-toggle-page").length) { + this.dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on("click", (function(_this) { + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.togglePage(); + }; + })(this)); + } + if (this.options.selectable) { + selector = ".dropdown-content a"; + if (this.dropdown.find(".dropdown-toggle-page").length) { + selector = ".dropdown-page-one .dropdown-content a"; + } + this.dropdown.on("click", selector, function(e) { + var $el, selected; + $el = $(this); + selected = self.rowClicked($el); + if (self.options.clicked) { + self.options.clicked(selected, $el, e); + } + return $el.trigger('blur'); + }); + } + } + + GitLabDropdown.prototype.getElement = function(selector) { + return this.dropdown.find(selector); + }; + + GitLabDropdown.prototype.toggleLoading = function() { + return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); + }; + + GitLabDropdown.prototype.togglePage = function() { + var menu; + menu = $('.dropdown-menu', this.dropdown); + if (menu.hasClass(PAGE_TWO_CLASS)) { + if (this.remote) { + this.remote.execute(); + } + } + menu.toggleClass(PAGE_TWO_CLASS); + return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); + }; + + GitLabDropdown.prototype.parseData = function(data) { + var full_html, groupData, html, name; + this.renderedData = data; + if (this.options.filterable && data.length === 0) { + html = [this.noResults()]; + } else { + if (gl.utils.isObject(data)) { + html = []; + for (name in data) { + groupData = data[name]; + html.push(this.renderItem({ + header: name + }, name)); + this.renderData(groupData, name).map(function(item) { + return html.push(item); + }); + } + } else { + html = this.renderData(data); + } + } + full_html = this.renderMenu(html); + return this.appendMenu(full_html); + }; + + GitLabDropdown.prototype.renderData = function(data, group) { + if (group == null) { + group = false; + } + return data.map((function(_this) { + return function(obj, index) { + return _this.renderItem(obj, group, index); + }; + })(this)); + }; + + GitLabDropdown.prototype.shouldPropagate = function(e) { + var $target; + if (this.options.multiSelect) { + $target = $(e.target); + if (!$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) { + e.stopPropagation(); + return false; + } else { + return true; + } + } + }; + + GitLabDropdown.prototype.opened = function() { + var contentHtml; + currentIndex = -1; + this.addArrowKeyEvent(); + if (this.options.setIndeterminateIds) { + this.options.setIndeterminateIds.call(this); + } + if (this.options.setActiveIds) { + this.options.setActiveIds.call(this); + } + if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + this.parseData(this.fullData); + } + contentHtml = $('.dropdown-content', this.dropdown).html(); + if (this.remote && contentHtml === "") { + this.remote.execute(); + } + if (this.options.filterable) { + this.filterInput.focus(); + } + return this.dropdown.trigger('shown.gl.dropdown'); + }; + + GitLabDropdown.prototype.hidden = function(e) { + var $input; + this.removeArrayKeyEvent(); + $input = this.dropdown.find(".dropdown-input-field"); + if (this.options.filterable) { + $input.blur().val(""); + } + if (!this.options.persistWhenHide) { + $input.trigger("keyup"); + } + if (this.dropdown.find(".dropdown-toggle-page").length) { + $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); + } + if (this.options.hidden) { + this.options.hidden.call(this, e); + } + return this.dropdown.trigger('hidden.gl.dropdown'); + }; + + GitLabDropdown.prototype.renderMenu = function(html) { + var menu_html; + menu_html = ""; + if (this.options.renderMenu) { + menu_html = this.options.renderMenu(html); + } else { + menu_html = $('<ul />').append(html); + } + return menu_html; + }; + + GitLabDropdown.prototype.appendMenu = function(html) { + var selector; + selector = '.dropdown-content'; + if (this.dropdown.find(".dropdown-toggle-page").length) { + selector = ".dropdown-page-one .dropdown-content"; + } + return $(selector, this.dropdown).empty().append(html); + }; + + GitLabDropdown.prototype.renderItem = function(data, group, index) { + var cssClass, field, fieldName, groupAttrs, html, selected, text, url, value; + if (group == null) { + group = false; + } + if (index == null) { + index = false; + } + html = ""; + if (data === "divider") { + return "<li class='divider'></li>"; + } + if (data === "separator") { + return "<li class='separator'></li>"; + } + if (data.header != null) { + return "<li class='dropdown-header'>" + data.header + "</li>"; + } + if (this.options.renderRow) { + html = this.options.renderRow.call(this.options, data, this); + } else { + if (!selected) { + value = this.options.id ? this.options.id(data) : data.id; + fieldName = this.options.fieldName; + field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']"); + if (field.length) { + selected = true; + } + } + if (this.options.url != null) { + url = this.options.url(data); + } else { + url = data.url != null ? data.url : '#'; + } + if (this.options.text != null) { + text = this.options.text(data); + } else { + text = data.text != null ? data.text : ''; + } + cssClass = ""; + if (selected) { + cssClass = "is-active"; + } + if (this.highlight) { + text = this.highlightTextMatches(text, this.filterInput.val()); + } + if (group) { + groupAttrs = "data-group='" + group + "' data-index='" + index + "'"; + } else { + groupAttrs = ''; + } + html = "<li> <a href='" + url + "' " + groupAttrs + " class='" + cssClass + "'> " + text + " </a> </li>"; + } + return html; + }; + + GitLabDropdown.prototype.highlightTextMatches = function(text, term) { + var occurrences; + occurrences = fuzzaldrinPlus.match(text, term); + return text.split('').map(function(character, i) { + if (indexOf.call(occurrences, i) >= 0) { + return "<b>" + character + "</b>"; + } else { + return character; + } + }).join(''); + }; + + GitLabDropdown.prototype.noResults = function() { + var html; + return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>"; + }; + + GitLabDropdown.prototype.highlightRow = function(index) { + var selector; + if (this.filterInput.val() !== "") { + selector = '.dropdown-content li:first-child a'; + if (this.dropdown.find(".dropdown-toggle-page").length) { + selector = ".dropdown-page-one .dropdown-content li:first-child a"; + } + return this.getElement(selector).addClass('is-focused'); + } + }; + + GitLabDropdown.prototype.rowClicked = function(el) { + var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value; + fieldName = this.options.fieldName; + isInput = $(this.el).is('input'); + if (this.renderedData) { + groupName = el.data('group'); + if (groupName) { + selectedIndex = el.data('index'); + selectedObject = this.renderedData[groupName][selectedIndex]; + } else { + selectedIndex = el.closest('li').index(); + selectedObject = this.renderedData[selectedIndex]; + } + } + value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id; + if (isInput) { + field = $(this.el); + } else { + field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']"); + } + if (el.hasClass(ACTIVE_CLASS)) { + el.removeClass(ACTIVE_CLASS); + if (isInput) { + field.val(''); + } else { + field.remove(); + } + if (this.options.toggleLabel) { + return this.updateLabel(selectedObject, el, this); + } else { + return selectedObject; + } + } else if (el.hasClass(INDETERMINATE_CLASS)) { + el.addClass(ACTIVE_CLASS); + el.removeClass(INDETERMINATE_CLASS); + if (value == null) { + field.remove(); + } + if (!field.length && fieldName) { + this.addInput(fieldName, value); + } + return selectedObject; + } else { + if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { + this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS); + if (!isInput) { + this.dropdown.parent().find("input[name='" + fieldName + "']").remove(); + } + } + if (value == null) { + field.remove(); + } + el.addClass(ACTIVE_CLASS); + if (this.options.toggleLabel) { + this.updateLabel(selectedObject, el, this); + } + if (value != null) { + if (!field.length && fieldName) { + this.addInput(fieldName, value); + } else { + field.val(value).trigger('change'); + } + } + return selectedObject; + } + }; + + GitLabDropdown.prototype.addInput = function(fieldName, value) { + var $input; + $input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value); + if (this.options.inputId != null) { + $input.attr('id', this.options.inputId); + } + return this.dropdown.before($input); + }; + + GitLabDropdown.prototype.selectRowAtIndex = function(index) { + var $el, selector; + selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(" + index + ") a"; + if (this.dropdown.find(".dropdown-toggle-page").length) { + selector = ".dropdown-page-one " + selector; + } + $el = $(selector, this.dropdown); + if ($el.length) { + return $el.first().trigger('click'); + } + }; + + GitLabDropdown.prototype.addArrowKeyEvent = function() { + var $input, ARROW_KEY_CODES, selector; + ARROW_KEY_CODES = [38, 40]; + $input = this.dropdown.find(".dropdown-input-field"); + selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator):visible'; + if (this.dropdown.find(".dropdown-toggle-page").length) { + selector = ".dropdown-page-one " + selector; + } + return $('body').on('keydown', (function(_this) { + return function(e) { + var $listItems, PREV_INDEX, currentKeyCode; + currentKeyCode = e.which; + if (ARROW_KEY_CODES.indexOf(currentKeyCode) >= 0) { + e.preventDefault(); + e.stopImmediatePropagation(); + PREV_INDEX = currentIndex; + $listItems = $(selector, _this.dropdown); + if (currentKeyCode === 40) { + if (currentIndex < ($listItems.length - 1)) { + currentIndex += 1; + } + } else if (currentKeyCode === 38) { + if (currentIndex > 0) { + currentIndex -= 1; + } + } + if (currentIndex !== PREV_INDEX) { + _this.highlightRowAtIndex($listItems, currentIndex); + } + return false; + } + if (currentKeyCode === 13 && currentIndex !== -1) { + return _this.selectRowAtIndex($('.is-focused', _this.dropdown).closest('li').index() - 1); + } + }; + })(this)); + }; + + GitLabDropdown.prototype.removeArrayKeyEvent = function() { + return $('body').off('keydown'); + }; + + GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { + var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop; + $('.is-focused', this.dropdown).removeClass('is-focused'); + $listItem = $listItems.eq(index); + $listItem.find('a:first-child').addClass("is-focused"); + $dropdownContent = $listItem.closest('.dropdown-content'); + dropdownScrollTop = $dropdownContent.scrollTop(); + dropdownContentHeight = $dropdownContent.outerHeight(); + dropdownContentTop = $dropdownContent.prop('offsetTop'); + dropdownContentBottom = dropdownContentTop + dropdownContentHeight; + listItemHeight = $listItem.outerHeight(); + listItemTop = $listItem.prop('offsetTop'); + listItemBottom = listItemTop + listItemHeight; + if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { + return $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom); + } else if (listItemTop < dropdownContentTop + dropdownScrollTop) { + return $dropdownContent.scrollTop(listItemTop - dropdownContentTop); + } + }; + + GitLabDropdown.prototype.updateLabel = function(selected, el, instance) { + if (selected == null) { + selected = null; + } + if (el == null) { + el = null; + } + if (instance == null) { + instance = null; + } + return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance)); + }; + + return GitLabDropdown; + + })(); + + $.fn.glDropdown = function(opts) { + return this.each(function() { + if (!$.data(this, 'glDropdown')) { + return $.data(this, 'glDropdown', new GitLabDropdown(this, opts)); + } + }); + }; + +}).call(this); diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee deleted file mode 100644 index 7086ece29b8..00000000000 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ /dev/null @@ -1,638 +0,0 @@ -class GitLabDropdownFilter - BLUR_KEYCODES = [27, 40] - ARROW_KEY_CODES = [38, 40] - HAS_VALUE_CLASS = "has-value" - - constructor: (@input, @options) -> - { - @filterInputBlur = true - } = @options - - $inputContainer = @input.parent() - $clearButton = $inputContainer.find('.js-dropdown-input-clear') - - @indeterminateIds = [] - - # Clear click - $clearButton.on 'click', (e) => - e.preventDefault() - e.stopPropagation() - @input - .val('') - .trigger('keyup') - .focus() - - # Key events - timeout = "" - @input.on "keyup", (e) => - keyCode = e.which - - return if ARROW_KEY_CODES.indexOf(keyCode) >= 0 - - if @input.val() isnt "" and !$inputContainer.hasClass HAS_VALUE_CLASS - $inputContainer.addClass HAS_VALUE_CLASS - else if @input.val() is "" and $inputContainer.hasClass HAS_VALUE_CLASS - $inputContainer.removeClass HAS_VALUE_CLASS - - if keyCode is 13 - return false - - # Only filter asynchronously only if option remote is set - if @options.remote - clearTimeout timeout - timeout = setTimeout => - blur_field = @shouldBlur keyCode - - if blur_field and @filterInputBlur - @input.blur() - - @options.query @input.val(), (data) => - @options.callback(data) - , 250 - else - @filter @input.val() - - shouldBlur: (keyCode) -> - return BLUR_KEYCODES.indexOf(keyCode) >= 0 - - filter: (search_text) -> - @options.onFilter(search_text) if @options.onFilter - data = @options.data() - - if data? and not @options.filterByText - results = data - - if search_text isnt '' - # When data is an array of objects therefore [object Array] e.g. - # [ - # { prop: 'foo' }, - # { prop: 'baz' } - # ] - if _.isArray(data) - results = fuzzaldrinPlus.filter(data, search_text, - key: @options.keys - ) - else - # If data is grouped therefore an [object Object]. e.g. - # { - # groupName1: [ - # { prop: 'foo' }, - # { prop: 'baz' } - # ], - # groupName2: [ - # { prop: 'abc' }, - # { prop: 'def' } - # ] - # } - if gl.utils.isObject data - results = {} - for key, group of data - tmp = fuzzaldrinPlus.filter(group, search_text, - key: @options.keys - ) - - if tmp.length - results[key] = tmp.map (item) -> item - - @options.callback results - else - elements = @options.elements() - - if search_text - elements.each -> - $el = $(@) - matches = fuzzaldrinPlus.match($el.text().trim(), search_text) - - unless $el.is('.dropdown-header') - if matches.length - $el.show() - else - $el.hide() - else - elements.show() - -class GitLabDropdownRemote - constructor: (@dataEndpoint, @options) -> - - execute: -> - if typeof @dataEndpoint is "string" - @fetchData() - else if typeof @dataEndpoint is "function" - if @options.beforeSend - @options.beforeSend() - - # Fetch the data by calling the data funcfion - @dataEndpoint "", (data) => - if @options.success - @options.success(data) - - if @options.beforeSend - @options.beforeSend() - - # Fetch the data through ajax if the data is a string - fetchData: -> - $.ajax( - url: @dataEndpoint, - dataType: @options.dataType, - beforeSend: => - if @options.beforeSend - @options.beforeSend() - success: (data) => - if @options.success - @options.success(data) - ) - -class GitLabDropdown - LOADING_CLASS = "is-loading" - PAGE_TWO_CLASS = "is-page-two" - ACTIVE_CLASS = "is-active" - INDETERMINATE_CLASS = "is-indeterminate" - currentIndex = -1 - - FILTER_INPUT = '.dropdown-input .dropdown-input-field' - - constructor: (@el, @options) -> - self = @ - selector = $(@el).data "target" - @dropdown = if selector? then $(selector) else $(@el).parent() - - # Set Defaults - { - # If no input is passed create a default one - @filterInput = @getElement(FILTER_INPUT) - @highlight = false - @filterInputBlur = true - } = @options - - self = @ - - # If selector was passed - if _.isString(@filterInput) - @filterInput = @getElement(@filterInput) - - searchFields = if @options.search then @options.search.fields else []; - - if @options.data - # If we provided data - # data could be an array of objects or a group of arrays - if _.isObject(@options.data) and not _.isFunction(@options.data) - @fullData = @options.data - @parseData @options.data - else - # Remote data - @remote = new GitLabDropdownRemote @options.data, { - dataType: @options.dataType, - beforeSend: @toggleLoading.bind(@) - success: (data) => - @fullData = data - - @parseData @fullData - - @filter.input.trigger('keyup') if @options.filterable and @filter and @filter.input - } - - # Init filterable - if @options.filterable - @filter = new GitLabDropdownFilter @filterInput, - filterInputBlur: @filterInputBlur - filterByText: @options.filterByText - onFilter: @options.onFilter - remote: @options.filterRemote - query: @options.data - keys: searchFields - elements: => - selector = '.dropdown-content li:not(.divider)' - - if @dropdown.find('.dropdown-toggle-page').length - selector = ".dropdown-page-one #{selector}" - - return $(selector) - data: => - return @fullData - callback: (data) => - @parseData data - - unless @filterInput.val() is '' - selector = '.dropdown-content li:not(.divider):visible' - - if @dropdown.find('.dropdown-toggle-page').length - selector = ".dropdown-page-one #{selector}" - - $(selector, @dropdown) - .first() - .find('a') - .addClass('is-focused') - - currentIndex = 0 - - - # Event listeners - - @dropdown.on "shown.bs.dropdown", @opened - @dropdown.on "hidden.bs.dropdown", @hidden - $(@el).on "update.label", @updateLabel - @dropdown.on "click", ".dropdown-menu, .dropdown-menu-close", @shouldPropagate - @dropdown.on 'keyup', (e) => - if e.which is 27 # Escape key - $('.dropdown-menu-close', @dropdown).trigger 'click' - @dropdown.on 'blur', 'a', (e) => - if e.relatedTarget? - $relatedTarget = $(e.relatedTarget) - $dropdownMenu = $relatedTarget.closest('.dropdown-menu') - - if $dropdownMenu.length is 0 - @dropdown.removeClass('open') - - if @dropdown.find(".dropdown-toggle-page").length - @dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on "click", (e) => - e.preventDefault() - e.stopPropagation() - - @togglePage() - - if @options.selectable - selector = ".dropdown-content a" - - if @dropdown.find(".dropdown-toggle-page").length - selector = ".dropdown-page-one .dropdown-content a" - - @dropdown.on "click", selector, (e) -> - $el = $(@) - selected = self.rowClicked $el - - if self.options.clicked - self.options.clicked(selected, $el, e) - - $el.trigger('blur') - - # Finds an element inside wrapper element - getElement: (selector) -> - @dropdown.find selector - - toggleLoading: -> - $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS - - togglePage: -> - menu = $('.dropdown-menu', @dropdown) - - if menu.hasClass(PAGE_TWO_CLASS) - if @remote - @remote.execute() - - menu.toggleClass PAGE_TWO_CLASS - - # Focus first visible input on active page - @dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus() - - parseData: (data) -> - @renderedData = data - - if @options.filterable and data.length is 0 - # render no matching results - html = [@noResults()] - else - # Handle array groups - if gl.utils.isObject data - html = [] - for name, groupData of data - # Add header for each group - html.push(@renderItem(header: name, name)) - - @renderData(groupData, name) - .map (item) -> - html.push item - else - # Render each row - html = @renderData(data) - - # Render the full menu - full_html = @renderMenu(html) - - @appendMenu(full_html) - - renderData: (data, group = false) -> - data.map (obj, index) => - return @renderItem(obj, group, index) - - shouldPropagate: (e) => - if @options.multiSelect - $target = $(e.target) - - if not $target.hasClass('dropdown-menu-close') and not $target.hasClass('dropdown-menu-close-icon') and not $target.data('is-link') - e.stopPropagation() - return false - else - return true - - opened: => - @addArrowKeyEvent() - - if @options.setIndeterminateIds - @options.setIndeterminateIds.call(@) - - if @options.setActiveIds - @options.setActiveIds.call(@) - - # Makes indeterminate items effective - if @fullData and @dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update') - @parseData @fullData - - contentHtml = $('.dropdown-content', @dropdown).html() - if @remote && contentHtml is "" - @remote.execute() - - if @options.filterable - @filterInput.focus() - - @dropdown.trigger('shown.gl.dropdown') - - hidden: (e) => - @removeArrayKeyEvent() - - $input = @dropdown.find(".dropdown-input-field") - - if @options.filterable - $input - .blur() - .val("") - - # Triggering 'keyup' will re-render the dropdown which is not always required - # specially if we want to keep the state of the dropdown needed for bulk-assignment - if not @options.persistWhenHide - $input.trigger("keyup") - - if @dropdown.find(".dropdown-toggle-page").length - $('.dropdown-menu', @dropdown).removeClass PAGE_TWO_CLASS - - if @options.hidden - @options.hidden.call(@,e) - - @dropdown.trigger('hidden.gl.dropdown') - - - # Render the full menu - renderMenu: (html) -> - menu_html = "" - - if @options.renderMenu - menu_html = @options.renderMenu(html) - else - menu_html = $('<ul />') - .append(html) - - return menu_html - - # Append the menu into the dropdown - appendMenu: (html) -> - selector = '.dropdown-content' - if @dropdown.find(".dropdown-toggle-page").length - selector = ".dropdown-page-one .dropdown-content" - $(selector, @dropdown) - .empty() - .append(html) - - # Render the row - renderItem: (data, group = false, index = false) -> - html = "" - - # Divider - return "<li class='divider'></li>" if data is "divider" - - # Separator is a full-width divider - return "<li class='separator'></li>" if data is "separator" - - # Header - return "<li class='dropdown-header'>#{data.header}</li>" if data.header? - - if @options.renderRow - # Call the render function - html = @options.renderRow.call(@options, data, @) - else - if not selected - value = if @options.id then @options.id(data) else data.id - fieldName = @options.fieldName - field = @dropdown.parent().find("input[name='#{fieldName}'][value='#{value}']") - if field.length - selected = true - - # Set URL - if @options.url? - url = @options.url(data) - else - url = if data.url? then data.url else '#' - - # Set Text - if @options.text? - text = @options.text(data) - else - text = if data.text? then data.text else '' - - cssClass = ""; - - if selected - cssClass = "is-active" - - if @highlight - text = @highlightTextMatches(text, @filterInput.val()) - - if group - groupAttrs = "data-group='#{group}' data-index='#{index}'" - else - groupAttrs = '' - - html = "<li> - <a href='#{url}' #{groupAttrs} class='#{cssClass}'> - #{text} - </a> - </li>" - - return html - - highlightTextMatches: (text, term) -> - occurrences = fuzzaldrinPlus.match(text, term) - text.split('').map((character, i) -> - if i in occurrences then "<b>#{character}</b>" else character - ).join('') - - noResults: -> - html = "<li class='dropdown-menu-empty-link'> - <a href='#' class='is-focused'> - No matching results. - </a> - </li>" - - highlightRow: (index) -> - if @filterInput.val() isnt "" - selector = '.dropdown-content li:first-child a' - if @dropdown.find(".dropdown-toggle-page").length - selector = ".dropdown-page-one .dropdown-content li:first-child a" - - @getElement(selector).addClass 'is-focused' - - rowClicked: (el) -> - fieldName = @options.fieldName - isInput = $(@el).is('input') - - if @renderedData - groupName = el.data('group') - if groupName - selectedIndex = el.data('index') - selectedObject = @renderedData[groupName][selectedIndex] - else - selectedIndex = el.closest('li').index() - selectedObject = @renderedData[selectedIndex] - - value = if @options.id then @options.id(selectedObject, el) else selectedObject.id - - if isInput - field = $(@el) - else - field = @dropdown.parent().find("input[name='#{fieldName}'][value='#{value}']") - - if el.hasClass(ACTIVE_CLASS) - el.removeClass(ACTIVE_CLASS) - - if isInput - field.val('') - else - field.remove() - - # Toggle the dropdown label - if @options.toggleLabel - @updateLabel(selectedObject, el, @) - else - selectedObject - else if el.hasClass(INDETERMINATE_CLASS) - el.addClass ACTIVE_CLASS - el.removeClass INDETERMINATE_CLASS - - if not value? - field.remove() - - if not field.length and fieldName - @addInput(fieldName, value) - - return selectedObject - else - if not @options.multiSelect or el.hasClass('dropdown-clear-active') - @dropdown.find(".#{ACTIVE_CLASS}").removeClass ACTIVE_CLASS - - unless isInput - @dropdown.parent().find("input[name='#{fieldName}']").remove() - - if !value? - field.remove() - - # Toggle active class for the tick mark - el.addClass ACTIVE_CLASS - - # Toggle the dropdown label - if @options.toggleLabel - @updateLabel(selectedObject, el, @) - if value? - if !field.length and fieldName - @addInput(fieldName, value) - else - field - .val value - .trigger 'change' - - return selectedObject - - addInput: (fieldName, value)-> - # Create hidden input for form - $input = $('<input>').attr('type', 'hidden') - .attr('name', fieldName) - .val(value) - - if @options.inputId? - $input.attr('id', @options.inputId) - - @dropdown.before $input - - selectRowAtIndex: (e, index) -> - selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(#{index}) a" - - if @dropdown.find(".dropdown-toggle-page").length - selector = ".dropdown-page-one #{selector}" - - # simulate a click on the first link - $el = $(selector, @dropdown) - - if $el.length - e.preventDefault() - e.stopImmediatePropagation() - $el.first().trigger('click') - - addArrowKeyEvent: -> - ARROW_KEY_CODES = [38, 40] - $input = @dropdown.find(".dropdown-input-field") - - selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator)' - if @dropdown.find(".dropdown-toggle-page").length - selector = ".dropdown-page-one #{selector}" - - $('body').on 'keydown', (e) => - currentKeyCode = e.which - - if ARROW_KEY_CODES.indexOf(currentKeyCode) >= 0 - e.preventDefault() - e.stopImmediatePropagation() - - PREV_INDEX = currentIndex - $listItems = $(selector, @dropdown) - - # if @options.filterable - # $input.blur() - - if currentKeyCode is 40 - # Move down - currentIndex += 1 if currentIndex < ($listItems.length - 1) - else if currentKeyCode is 38 - # Move up - currentIndex -= 1 if currentIndex > 0 - - @highlightRowAtIndex($listItems, currentIndex) if currentIndex isnt PREV_INDEX - - return false - - if currentKeyCode is 13 and currentIndex isnt -1 - @selectRowAtIndex e, currentIndex - - removeArrayKeyEvent: -> - $('body').off 'keydown' - - highlightRowAtIndex: ($listItems, index) -> - # Remove the class for the previously focused row - $('.is-focused', @dropdown).removeClass 'is-focused' - - # Update the class for the row at the specific index - $listItem = $listItems.eq(index) - $listItem.find('a:first-child').addClass "is-focused" - - # Dropdown content scroll area - $dropdownContent = $listItem.closest('.dropdown-content') - dropdownScrollTop = $dropdownContent.scrollTop() - dropdownContentHeight = $dropdownContent.outerHeight() - dropdownContentTop = $dropdownContent.prop('offsetTop') - dropdownContentBottom = dropdownContentTop + dropdownContentHeight - - # Get the offset bottom of the list item - listItemHeight = $listItem.outerHeight() - listItemTop = $listItem.prop('offsetTop') - listItemBottom = listItemTop + listItemHeight - - if listItemBottom > dropdownContentBottom + dropdownScrollTop - # Scroll the dropdown content down - $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom) - else if listItemTop < dropdownContentTop + dropdownScrollTop - # Scroll the dropdown content up - $dropdownContent.scrollTop(listItemTop - dropdownContentTop) - - updateLabel: (selected = null, el = null, instance = null) => - $(@el).find(".dropdown-toggle-text").text @options.toggleLabel(selected, el, instance) - -$.fn.glDropdown = (opts) -> - return @.each -> - if (!$.data @, 'glDropdown') - $.data(@, 'glDropdown', new GitLabDropdown @, opts) diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js new file mode 100644 index 00000000000..528a673eb15 --- /dev/null +++ b/app/assets/javascripts/gl_form.js @@ -0,0 +1,53 @@ +(function() { + this.GLForm = (function() { + function GLForm(form) { + this.form = form; + this.textarea = this.form.find('textarea.js-gfm-input'); + this.destroy(); + this.setupForm(); + this.form.data('gl-form', this); + } + + GLForm.prototype.destroy = function() { + this.clearEventListeners(); + return this.form.data('gl-form', null); + }; + + GLForm.prototype.setupForm = function() { + var isNewForm; + isNewForm = this.form.is(':not(.gfm-form)'); + this.form.removeClass('js-new-note-form'); + if (isNewForm) { + this.form.find('.div-dropzone').remove(); + this.form.addClass('gfm-form'); + disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); + GitLab.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); + new DropzoneInput(this.form); + autosize(this.textarea); + this.addEventListeners(); + gl.text.init(this.form); + } + this.form.find('.js-note-discard').hide(); + return this.form.show(); + }; + + GLForm.prototype.clearEventListeners = function() { + this.textarea.off('focus'); + this.textarea.off('blur'); + return gl.text.removeListeners(this.form); + }; + + GLForm.prototype.addEventListeners = function() { + this.textarea.on('focus', function() { + return $(this).closest('.md-area').addClass('is-focused'); + }); + return this.textarea.on('blur', function() { + return $(this).closest('.md-area').removeClass('is-focused'); + }); + }; + + return GLForm; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/gl_form.js.coffee b/app/assets/javascripts/gl_form.js.coffee deleted file mode 100644 index 77512d187c9..00000000000 --- a/app/assets/javascripts/gl_form.js.coffee +++ /dev/null @@ -1,54 +0,0 @@ -class @GLForm - constructor: (@form) -> - @textarea = @form.find('textarea.js-gfm-input') - - # Before we start, we should clean up any previous data for this form - @destroy() - - # Setup the form - @setupForm() - - @form.data 'gl-form', @ - - destroy: -> - # Clean form listeners - @clearEventListeners() - @form.data 'gl-form', null - - setupForm: -> - isNewForm = @form.is(':not(.gfm-form)') - - @form.removeClass 'js-new-note-form' - - if isNewForm - @form.find('.div-dropzone').remove() - @form.addClass('gfm-form') - disableButtonIfEmptyField @form.find('.js-note-text'), @form.find('.js-comment-button') - - # remove notify commit author checkbox for non-commit notes - GitLab.GfmAutoComplete.setup() - new DropzoneInput(@form) - - autosize(@textarea) - - # form and textarea event listeners - @addEventListeners() - - gl.text.init(@form) - - # hide discard button - @form.find('.js-note-discard').hide() - - @form.show() - - clearEventListeners: -> - @textarea.off 'focus' - @textarea.off 'blur' - gl.text.removeListeners(@form) - - addEventListeners: -> - @textarea.on 'focus', -> - $(@).closest('.md-area').addClass 'is-focused' - - @textarea.on 'blur', -> - $(@).closest('.md-area').removeClass 'is-focused' diff --git a/app/assets/javascripts/graphs/graphs_bundle.js b/app/assets/javascripts/graphs/graphs_bundle.js new file mode 100644 index 00000000000..b95faadc8e7 --- /dev/null +++ b/app/assets/javascripts/graphs/graphs_bundle.js @@ -0,0 +1,7 @@ + +/*= require_tree . */ + +(function() { + + +}).call(this); diff --git a/app/assets/javascripts/graphs/graphs_bundle.js.coffee b/app/assets/javascripts/graphs/graphs_bundle.js.coffee deleted file mode 100644 index e0f681acf0b..00000000000 --- a/app/assets/javascripts/graphs/graphs_bundle.js.coffee +++ /dev/null @@ -1,7 +0,0 @@ -# This is a manifest file that'll be compiled into including all the files listed below. -# Add new JavaScript/Coffee code in separate files in this directory and they'll automatically -# be included in the compiled file accessible from http://example.com/assets/application.js -# It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -# the compiled file. -# -#= require_tree . diff --git a/app/assets/javascripts/graphs/stat_graph.js b/app/assets/javascripts/graphs/stat_graph.js new file mode 100644 index 00000000000..f041980bc19 --- /dev/null +++ b/app/assets/javascripts/graphs/stat_graph.js @@ -0,0 +1,19 @@ +(function() { + this.StatGraph = (function() { + function StatGraph() {} + + StatGraph.log = {}; + + StatGraph.get_log = function() { + return this.log; + }; + + StatGraph.set_log = function(data) { + return this.log = data; + }; + + return StatGraph; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/graphs/stat_graph.js.coffee b/app/assets/javascripts/graphs/stat_graph.js.coffee deleted file mode 100644 index f36c71fd25e..00000000000 --- a/app/assets/javascripts/graphs/stat_graph.js.coffee +++ /dev/null @@ -1,6 +0,0 @@ -class @StatGraph - @log: {} - @get_log: -> - @log - @set_log: (data) -> - @log = data diff --git a/app/assets/javascripts/graphs/stat_graph_contributors.js b/app/assets/javascripts/graphs/stat_graph_contributors.js new file mode 100644 index 00000000000..927d241b357 --- /dev/null +++ b/app/assets/javascripts/graphs/stat_graph_contributors.js @@ -0,0 +1,112 @@ + +/*= require d3 */ + +(function() { + this.ContributorsStatGraph = (function() { + function ContributorsStatGraph() {} + + ContributorsStatGraph.prototype.init = function(log) { + var author_commits, total_commits; + this.parsed_log = ContributorsStatGraphUtil.parse_log(log); + this.set_current_field("commits"); + total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); + author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field); + this.add_master_graph(total_commits); + this.add_authors_graph(author_commits); + return this.change_date_header(); + }; + + ContributorsStatGraph.prototype.add_master_graph = function(total_data) { + this.master_graph = new ContributorsMasterGraph(total_data); + return this.master_graph.draw(); + }; + + ContributorsStatGraph.prototype.add_authors_graph = function(author_data) { + var limited_author_data; + this.authors = []; + limited_author_data = author_data.slice(0, 100); + return _.each(limited_author_data, (function(_this) { + return function(d) { + var author_graph, author_header; + author_header = _this.create_author_header(d); + $(".contributors-list").append(author_header); + _this.authors[d.author_name] = author_graph = new ContributorsAuthorGraph(d.dates); + return author_graph.draw(); + }; + })(this)); + }; + + ContributorsStatGraph.prototype.format_author_commit_info = function(author) { + var commits; + commits = $('<span/>', { + "class": 'graph-author-commits-count' + }); + commits.text(author.commits + " commits"); + return $('<span/>').append(commits); + }; + + ContributorsStatGraph.prototype.create_author_header = function(author) { + var author_commit_info, author_commit_info_span, author_email, author_name, list_item; + list_item = $('<li/>', { + "class": 'person', + style: 'display: block;' + }); + author_name = $('<h4>' + author.author_name + '</h4>'); + author_email = $('<p class="graph-author-email">' + author.author_email + '</p>'); + author_commit_info_span = $('<span/>', { + "class": 'commits' + }); + author_commit_info = this.format_author_commit_info(author); + author_commit_info_span.html(author_commit_info); + list_item.append(author_name); + list_item.append(author_email); + list_item.append(author_commit_info_span); + return list_item; + }; + + ContributorsStatGraph.prototype.redraw_master = function() { + var total_data; + total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); + this.master_graph.set_data(total_data); + return this.master_graph.redraw(); + }; + + ContributorsStatGraph.prototype.redraw_authors = function() { + var author_commits, x_domain; + $("ol").html(""); + x_domain = ContributorsGraph.prototype.x_domain; + author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field, x_domain); + return _.each(author_commits, (function(_this) { + return function(d) { + _this.redraw_author_commit_info(d); + $(_this.authors[d.author_name].list_item).appendTo("ol"); + _this.authors[d.author_name].set_data(d.dates); + return _this.authors[d.author_name].redraw(); + }; + })(this)); + }; + + ContributorsStatGraph.prototype.set_current_field = function(field) { + return this.field = field; + }; + + ContributorsStatGraph.prototype.change_date_header = function() { + var print, print_date_format, x_domain; + x_domain = ContributorsGraph.prototype.x_domain; + print_date_format = d3.time.format("%B %e %Y"); + print = print_date_format(x_domain[0]) + " - " + print_date_format(x_domain[1]); + return $("#date_header").text(print); + }; + + ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) { + var author_commit_info, author_list_item; + author_list_item = $(this.authors[author.author_name].list_item); + author_commit_info = this.format_author_commit_info(author); + return author_list_item.find("span").html(author_commit_info); + }; + + return ContributorsStatGraph; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/graphs/stat_graph_contributors.js.coffee b/app/assets/javascripts/graphs/stat_graph_contributors.js.coffee deleted file mode 100644 index 1d9fae7cf79..00000000000 --- a/app/assets/javascripts/graphs/stat_graph_contributors.js.coffee +++ /dev/null @@ -1,71 +0,0 @@ -#= require d3 - -class @ContributorsStatGraph - init: (log) -> - @parsed_log = ContributorsStatGraphUtil.parse_log(log) - @set_current_field("commits") - total_commits = ContributorsStatGraphUtil.get_total_data(@parsed_log, @field) - author_commits = ContributorsStatGraphUtil.get_author_data(@parsed_log, @field) - @add_master_graph(total_commits) - @add_authors_graph(author_commits) - @change_date_header() - add_master_graph: (total_data) -> - @master_graph = new ContributorsMasterGraph(total_data) - @master_graph.draw() - add_authors_graph: (author_data) -> - @authors = [] - limited_author_data = author_data.slice(0, 100) - _.each(limited_author_data, (d) => - author_header = @create_author_header(d) - $(".contributors-list").append(author_header) - @authors[d.author_name] = author_graph = new ContributorsAuthorGraph(d.dates) - author_graph.draw() - ) - format_author_commit_info: (author) -> - commits = $('<span/>', { - class: 'graph-author-commits-count' - }) - commits.text(author.commits + " commits") - $('<span/>').append(commits) - - create_author_header: (author) -> - list_item = $('<li/>', { - class: 'person' - style: 'display: block;' - }) - author_name = $('<h4>' + author.author_name + '</h4>') - author_email = $('<p class="graph-author-email">' + author.author_email + '</p>') - author_commit_info_span = $('<span/>', { - class: 'commits' - }) - author_commit_info = @format_author_commit_info(author) - author_commit_info_span.html(author_commit_info) - list_item.append(author_name) - list_item.append(author_email) - list_item.append(author_commit_info_span) - list_item - redraw_master: -> - total_data = ContributorsStatGraphUtil.get_total_data(@parsed_log, @field) - @master_graph.set_data(total_data) - @master_graph.redraw() - redraw_authors: -> - $("ol").html("") - x_domain = ContributorsGraph.prototype.x_domain - author_commits = ContributorsStatGraphUtil.get_author_data(@parsed_log, @field, x_domain) - _.each(author_commits, (d) => - @redraw_author_commit_info(d) - $(@authors[d.author_name].list_item).appendTo("ol") - @authors[d.author_name].set_data(d.dates) - @authors[d.author_name].redraw() - ) - set_current_field: (field) -> - @field = field - change_date_header: -> - x_domain = ContributorsGraph.prototype.x_domain - print_date_format = d3.time.format("%B %e %Y") - print = print_date_format(x_domain[0]) + " - " + print_date_format(x_domain[1]) - $("#date_header").text(print) - redraw_author_commit_info: (author) -> - author_list_item = $(@authors[author.author_name].list_item) - author_commit_info = @format_author_commit_info(author) - author_list_item.find("span").html(author_commit_info) diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js new file mode 100644 index 00000000000..a646ca1d84f --- /dev/null +++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js @@ -0,0 +1,279 @@ + +/*= require d3 */ + +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, + extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.ContributorsGraph = (function() { + function ContributorsGraph() {} + + ContributorsGraph.prototype.MARGIN = { + top: 20, + right: 20, + bottom: 30, + left: 50 + }; + + ContributorsGraph.prototype.x_domain = null; + + ContributorsGraph.prototype.y_domain = null; + + ContributorsGraph.prototype.dates = []; + + ContributorsGraph.set_x_domain = function(data) { + return ContributorsGraph.prototype.x_domain = data; + }; + + ContributorsGraph.set_y_domain = function(data) { + return ContributorsGraph.prototype.y_domain = [ + 0, d3.max(data, function(d) { + var ref, ref1; + return d.commits = (ref = (ref1 = d.commits) != null ? ref1 : d.additions) != null ? ref : d.deletions; + }) + ]; + }; + + ContributorsGraph.init_x_domain = function(data) { + return ContributorsGraph.prototype.x_domain = d3.extent(data, function(d) { + return d.date; + }); + }; + + ContributorsGraph.init_y_domain = function(data) { + return ContributorsGraph.prototype.y_domain = [ + 0, d3.max(data, function(d) { + var ref, ref1; + return d.commits = (ref = (ref1 = d.commits) != null ? ref1 : d.additions) != null ? ref : d.deletions; + }) + ]; + }; + + ContributorsGraph.init_domain = function(data) { + ContributorsGraph.init_x_domain(data); + return ContributorsGraph.init_y_domain(data); + }; + + ContributorsGraph.set_dates = function(data) { + return ContributorsGraph.prototype.dates = data; + }; + + ContributorsGraph.prototype.set_x_domain = function() { + return this.x.domain(this.x_domain); + }; + + ContributorsGraph.prototype.set_y_domain = function() { + return this.y.domain(this.y_domain); + }; + + ContributorsGraph.prototype.set_domain = function() { + this.set_x_domain(); + return this.set_y_domain(); + }; + + ContributorsGraph.prototype.create_scale = function(width, height) { + this.x = d3.time.scale().range([0, width]).clamp(true); + return this.y = d3.scale.linear().range([height, 0]).nice(); + }; + + ContributorsGraph.prototype.draw_x_axis = function() { + return this.svg.append("g").attr("class", "x axis").attr("transform", "translate(0, " + this.height + ")").call(this.x_axis); + }; + + ContributorsGraph.prototype.draw_y_axis = function() { + return this.svg.append("g").attr("class", "y axis").call(this.y_axis); + }; + + ContributorsGraph.prototype.set_data = function(data) { + return this.data = data; + }; + + return ContributorsGraph; + + })(); + + this.ContributorsMasterGraph = (function(superClass) { + extend(ContributorsMasterGraph, superClass); + + function ContributorsMasterGraph(data1) { + this.data = data1; + this.update_content = bind(this.update_content, this); + this.width = $('.content').width() - 70; + this.height = 200; + this.x = null; + this.y = null; + this.x_axis = null; + this.y_axis = null; + this.area = null; + this.svg = null; + this.brush = null; + this.x_max_domain = null; + } + + ContributorsMasterGraph.prototype.process_dates = function(data) { + var dates; + dates = this.get_dates(data); + this.parse_dates(data); + return ContributorsGraph.set_dates(dates); + }; + + ContributorsMasterGraph.prototype.get_dates = function(data) { + return _.pluck(data, 'date'); + }; + + ContributorsMasterGraph.prototype.parse_dates = function(data) { + var parseDate; + parseDate = d3.time.format("%Y-%m-%d").parse; + return data.forEach(function(d) { + return d.date = parseDate(d.date); + }); + }; + + ContributorsMasterGraph.prototype.create_scale = function() { + return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height); + }; + + ContributorsMasterGraph.prototype.create_axes = function() { + this.x_axis = d3.svg.axis().scale(this.x).orient("bottom"); + return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5); + }; + + ContributorsMasterGraph.prototype.create_svg = function() { + return this.svg = d3.select("#contributors-master").append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "tint-box").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")"); + }; + + ContributorsMasterGraph.prototype.create_area = function(x, y) { + return this.area = d3.svg.area().x(function(d) { + return x(d.date); + }).y0(this.height).y1(function(d) { + var ref, ref1, xa; + xa = d.commits = (ref = (ref1 = d.commits) != null ? ref1 : d.additions) != null ? ref : d.deletions; + return y(xa); + }).interpolate("basis"); + }; + + ContributorsMasterGraph.prototype.create_brush = function() { + return this.brush = d3.svg.brush().x(this.x).on("brushend", this.update_content); + }; + + ContributorsMasterGraph.prototype.draw_path = function(data) { + return this.svg.append("path").datum(data).attr("class", "area").attr("d", this.area); + }; + + ContributorsMasterGraph.prototype.add_brush = function() { + return this.svg.append("g").attr("class", "selection").call(this.brush).selectAll("rect").attr("height", this.height); + }; + + ContributorsMasterGraph.prototype.update_content = function() { + ContributorsGraph.set_x_domain(this.brush.empty() ? this.x_max_domain : this.brush.extent()); + return $("#brush_change").trigger('change'); + }; + + ContributorsMasterGraph.prototype.draw = function() { + this.process_dates(this.data); + this.create_scale(); + this.create_axes(); + ContributorsGraph.init_domain(this.data); + this.x_max_domain = this.x_domain; + this.set_domain(); + this.create_area(this.x, this.y); + this.create_svg(); + this.create_brush(); + this.draw_path(this.data); + this.draw_x_axis(); + this.draw_y_axis(); + return this.add_brush(); + }; + + ContributorsMasterGraph.prototype.redraw = function() { + this.process_dates(this.data); + ContributorsGraph.set_y_domain(this.data); + this.set_y_domain(); + this.svg.select("path").datum(this.data); + this.svg.select("path").attr("d", this.area); + return this.svg.select(".y.axis").call(this.y_axis); + }; + + return ContributorsMasterGraph; + + })(ContributorsGraph); + + this.ContributorsAuthorGraph = (function(superClass) { + extend(ContributorsAuthorGraph, superClass); + + function ContributorsAuthorGraph(data1) { + this.data = data1; + if ($(window).width() < 768) { + this.width = $('.content').width() - 80; + } else { + this.width = ($('.content').width() / 2) - 100; + } + this.height = 200; + this.x = null; + this.y = null; + this.x_axis = null; + this.y_axis = null; + this.area = null; + this.svg = null; + this.list_item = null; + } + + ContributorsAuthorGraph.prototype.create_scale = function() { + return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height); + }; + + ContributorsAuthorGraph.prototype.create_axes = function() { + this.x_axis = d3.svg.axis().scale(this.x).orient("bottom").ticks(8); + return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5); + }; + + ContributorsAuthorGraph.prototype.create_area = function(x, y) { + return this.area = d3.svg.area().x(function(d) { + var parseDate; + parseDate = d3.time.format("%Y-%m-%d").parse; + return x(parseDate(d)); + }).y0(this.height).y1((function(_this) { + return function(d) { + if (_this.data[d] != null) { + return y(_this.data[d]); + } else { + return y(0); + } + }; + })(this)).interpolate("basis"); + }; + + ContributorsAuthorGraph.prototype.create_svg = function() { + this.list_item = d3.selectAll(".person")[0].pop(); + return this.svg = d3.select(this.list_item).append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "spark").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")"); + }; + + ContributorsAuthorGraph.prototype.draw_path = function(data) { + return this.svg.append("path").datum(data).attr("class", "area-contributor").attr("d", this.area); + }; + + ContributorsAuthorGraph.prototype.draw = function() { + this.create_scale(); + this.create_axes(); + this.set_domain(); + this.create_area(this.x, this.y); + this.create_svg(); + this.draw_path(this.dates); + this.draw_x_axis(); + return this.draw_y_axis(); + }; + + ContributorsAuthorGraph.prototype.redraw = function() { + this.set_domain(); + this.svg.select("path").datum(this.dates); + this.svg.select("path").attr("d", this.area); + this.svg.select(".x.axis").call(this.x_axis); + return this.svg.select(".y.axis").call(this.y_axis); + }; + + return ContributorsAuthorGraph; + + })(ContributorsGraph); + +}).call(this); diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js.coffee b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js.coffee deleted file mode 100644 index 834a81af459..00000000000 --- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js.coffee +++ /dev/null @@ -1,173 +0,0 @@ -#= require d3 - -class @ContributorsGraph - MARGIN: - top: 20 - right: 20 - bottom: 30 - left: 50 - x_domain: null - y_domain: null - dates: [] - @set_x_domain: (data) => - @prototype.x_domain = data - @set_y_domain: (data) => - @prototype.y_domain = [0, d3.max(data, (d) -> - d.commits = d.commits ? d.additions ? d.deletions - )] - @init_x_domain: (data) => - @prototype.x_domain = d3.extent(data, (d) -> - d.date - ) - @init_y_domain: (data) => - @prototype.y_domain = [0, d3.max(data, (d) -> - d.commits = d.commits ? d.additions ? d.deletions - )] - @init_domain: (data) => - @init_x_domain(data) - @init_y_domain(data) - @set_dates: (data) => - @prototype.dates = data - set_x_domain: -> - @x.domain(@x_domain) - set_y_domain: -> - @y.domain(@y_domain) - set_domain: -> - @set_x_domain() - @set_y_domain() - create_scale: (width, height) -> - @x = d3.time.scale().range([0, width]).clamp(true) - @y = d3.scale.linear().range([height, 0]).nice() - draw_x_axis: -> - @svg.append("g").attr("class", "x axis").attr("transform", "translate(0, #{@height})") - .call(@x_axis) - draw_y_axis: -> - @svg.append("g").attr("class", "y axis").call(@y_axis) - set_data: (data) -> - @data = data - -class @ContributorsMasterGraph extends ContributorsGraph - constructor: (@data) -> - @width = $('.content').width() - 70 - @height = 200 - @x = null - @y = null - @x_axis = null - @y_axis = null - @area = null - @svg = null - @brush = null - @x_max_domain = null - process_dates: (data) -> - dates = @get_dates(data) - @parse_dates(data) - ContributorsGraph.set_dates(dates) - get_dates: (data) -> - _.pluck(data, 'date') - parse_dates: (data) -> - parseDate = d3.time.format("%Y-%m-%d").parse - data.forEach((d) -> - d.date = parseDate(d.date) - ) - create_scale: -> - super @width, @height - create_axes: -> - @x_axis = d3.svg.axis().scale(@x).orient("bottom") - @y_axis = d3.svg.axis().scale(@y).orient("left").ticks(5) - create_svg: -> - @svg = d3.select("#contributors-master").append("svg") - .attr("width", @width + @MARGIN.left + @MARGIN.right) - .attr("height", @height + @MARGIN.top + @MARGIN.bottom) - .attr("class", "tint-box") - .append("g") - .attr("transform", "translate(" + @MARGIN.left + "," + @MARGIN.top + ")") - create_area: (x, y) -> - @area = d3.svg.area().x((d) -> - x(d.date) - ).y0(@height).y1((d) -> - xa = d.commits = d.commits ? d.additions ? d.deletions - y(xa) - ).interpolate("basis") - create_brush: -> - @brush = d3.svg.brush().x(@x).on("brushend", @update_content) - draw_path: (data) -> - @svg.append("path").datum(data).attr("class", "area").attr("d", @area) - add_brush: -> - @svg.append("g").attr("class", "selection").call(@brush).selectAll("rect").attr("height", @height) - update_content: => - ContributorsGraph.set_x_domain(if @brush.empty() then @x_max_domain else @brush.extent()) - $("#brush_change").trigger('change') - draw: -> - @process_dates(@data) - @create_scale() - @create_axes() - ContributorsGraph.init_domain(@data) - @x_max_domain = @x_domain - @set_domain() - @create_area(@x, @y) - @create_svg() - @create_brush() - @draw_path(@data) - @draw_x_axis() - @draw_y_axis() - @add_brush() - redraw: -> - @process_dates(@data) - ContributorsGraph.set_y_domain(@data) - @set_y_domain() - @svg.select("path").datum(@data) - @svg.select("path").attr("d", @area) - @svg.select(".y.axis").call(@y_axis) - -class @ContributorsAuthorGraph extends ContributorsGraph - constructor: (@data) -> - # Don't split graph size in half for mobile devices. - if $(window).width() < 768 - @width = $('.content').width() - 80 - else - @width = ($('.content').width() / 2) - 100 - @height = 200 - @x = null - @y = null - @x_axis = null - @y_axis = null - @area = null - @svg = null - @list_item = null - create_scale: -> - super @width, @height - create_axes: -> - @x_axis = d3.svg.axis().scale(@x).orient("bottom").ticks(8) - @y_axis = d3.svg.axis().scale(@y).orient("left").ticks(5) - create_area: (x, y) -> - @area = d3.svg.area().x((d) -> - parseDate = d3.time.format("%Y-%m-%d").parse - x(parseDate(d)) - ).y0(@height).y1((d) => - if @data[d]? then y(@data[d]) else y(0) - ).interpolate("basis") - create_svg: -> - @list_item = d3.selectAll(".person")[0].pop() - @svg = d3.select(@list_item).append("svg") - .attr("width", @width + @MARGIN.left + @MARGIN.right) - .attr("height", @height + @MARGIN.top + @MARGIN.bottom) - .attr("class", "spark") - .append("g") - .attr("transform", "translate(" + @MARGIN.left + "," + @MARGIN.top + ")") - draw_path: (data) -> - @svg.append("path").datum(data).attr("class", "area-contributor").attr("d", @area) - draw: -> - @create_scale() - @create_axes() - @set_domain() - @create_area(@x, @y) - @create_svg() - @draw_path(@dates) - @draw_x_axis() - @draw_y_axis() - redraw: -> - @set_domain() - @svg.select("path").datum(@dates) - @svg.select("path").attr("d", @area) - @svg.select(".x.axis").call(@x_axis) - @svg.select(".y.axis").call(@y_axis) diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_util.js b/app/assets/javascripts/graphs/stat_graph_contributors_util.js new file mode 100644 index 00000000000..0d240bed8b6 --- /dev/null +++ b/app/assets/javascripts/graphs/stat_graph_contributors_util.js @@ -0,0 +1,135 @@ +(function() { + window.ContributorsStatGraphUtil = { + parse_log: function(log) { + var by_author, by_email, data, entry, i, len, total; + total = {}; + by_author = {}; + by_email = {}; + for (i = 0, len = log.length; i < len; i++) { + entry = log[i]; + if (total[entry.date] == null) { + this.add_date(entry.date, total); + } + data = by_author[entry.author_name] || by_email[entry.author_email]; + if (data == null) { + data = this.add_author(entry, by_author, by_email); + } + if (!data[entry.date]) { + this.add_date(entry.date, data); + } + this.store_data(entry, total[entry.date], data[entry.date]); + } + total = _.toArray(total); + by_author = _.toArray(by_author); + return { + total: total, + by_author: by_author + }; + }, + add_date: function(date, collection) { + collection[date] = {}; + return collection[date].date = date; + }, + add_author: function(author, by_author, by_email) { + var data; + data = {}; + data.author_name = author.author_name; + data.author_email = author.author_email; + by_author[author.author_name] = data; + return by_email[author.author_email] = data; + }, + store_data: function(entry, total, by_author) { + this.store_commits(total, by_author); + this.store_additions(entry, total, by_author); + return this.store_deletions(entry, total, by_author); + }, + store_commits: function(total, by_author) { + this.add(total, "commits", 1); + return this.add(by_author, "commits", 1); + }, + add: function(collection, field, value) { + if (collection[field] == null) { + collection[field] = 0; + } + return collection[field] += value; + }, + store_additions: function(entry, total, by_author) { + if (entry.additions == null) { + entry.additions = 0; + } + this.add(total, "additions", entry.additions); + return this.add(by_author, "additions", entry.additions); + }, + store_deletions: function(entry, total, by_author) { + if (entry.deletions == null) { + entry.deletions = 0; + } + this.add(total, "deletions", entry.deletions); + return this.add(by_author, "deletions", entry.deletions); + }, + get_total_data: function(parsed_log, field) { + var log, total_data; + log = parsed_log.total; + total_data = this.pick_field(log, field); + return _.sortBy(total_data, function(d) { + return d.date; + }); + }, + pick_field: function(log, field) { + var total_data; + total_data = []; + _.each(log, function(d) { + return total_data.push(_.pick(d, [field, 'date'])); + }); + return total_data; + }, + get_author_data: function(parsed_log, field, date_range) { + var author_data, log; + if (date_range == null) { + date_range = null; + } + log = parsed_log.by_author; + author_data = []; + _.each(log, (function(_this) { + return function(log_entry) { + var parsed_log_entry; + parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range); + if (!_.isEmpty(parsed_log_entry.dates)) { + return author_data.push(parsed_log_entry); + } + }; + })(this)); + return _.sortBy(author_data, function(d) { + return d[field]; + }).reverse(); + }, + parse_log_entry: function(log_entry, field, date_range) { + var parsed_entry; + parsed_entry = {}; + parsed_entry.author_name = log_entry.author_name; + parsed_entry.author_email = log_entry.author_email; + parsed_entry.dates = {}; + parsed_entry.commits = parsed_entry.additions = parsed_entry.deletions = 0; + _.each(_.omit(log_entry, 'author_name', 'author_email'), (function(_this) { + return function(value, key) { + if (_this.in_range(value.date, date_range)) { + parsed_entry.dates[value.date] = value[field]; + parsed_entry.commits += value.commits; + parsed_entry.additions += value.additions; + return parsed_entry.deletions += value.deletions; + } + }; + })(this)); + return parsed_entry; + }, + in_range: function(date, date_range) { + var ref; + if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) { + return true; + } else { + return false; + } + } + }; + +}).call(this); diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_util.js.coffee b/app/assets/javascripts/graphs/stat_graph_contributors_util.js.coffee deleted file mode 100644 index 31617c88b4a..00000000000 --- a/app/assets/javascripts/graphs/stat_graph_contributors_util.js.coffee +++ /dev/null @@ -1,98 +0,0 @@ -window.ContributorsStatGraphUtil = - parse_log: (log) -> - total = {} - by_author = {} - by_email = {} - for entry in log - @add_date(entry.date, total) unless total[entry.date]? - - data = by_author[entry.author_name] || by_email[entry.author_email] - data ?= @add_author(entry, by_author, by_email) - - @add_date(entry.date, data) unless data[entry.date] - @store_data(entry, total[entry.date], data[entry.date]) - total = _.toArray(total) - by_author = _.toArray(by_author) - total: total, by_author: by_author - - add_date: (date, collection) -> - collection[date] = {} - collection[date].date = date - - add_author: (author, by_author, by_email) -> - data = {} - data.author_name = author.author_name - data.author_email = author.author_email - by_author[author.author_name] = data - by_email[author.author_email] = data - - store_data: (entry, total, by_author) -> - @store_commits(total, by_author) - @store_additions(entry, total, by_author) - @store_deletions(entry, total, by_author) - - store_commits: (total, by_author) -> - @add(total, "commits", 1) - @add(by_author, "commits", 1) - - add: (collection, field, value) -> - collection[field] ?= 0 - collection[field] += value - - store_additions: (entry, total, by_author) -> - entry.additions ?= 0 - @add(total, "additions", entry.additions) - @add(by_author, "additions", entry.additions) - - store_deletions: (entry, total, by_author) -> - entry.deletions ?= 0 - @add(total, "deletions", entry.deletions) - @add(by_author, "deletions", entry.deletions) - - get_total_data: (parsed_log, field) -> - log = parsed_log.total - total_data = @pick_field(log, field) - _.sortBy(total_data, (d) -> - d.date - ) - pick_field: (log, field) -> - total_data = [] - _.each(log, (d) -> - total_data.push(_.pick(d, [field, 'date'])) - ) - total_data - - get_author_data: (parsed_log, field, date_range = null) -> - log = parsed_log.by_author - author_data = [] - - _.each(log, (log_entry) => - parsed_log_entry = @parse_log_entry(log_entry, field, date_range) - if not _.isEmpty(parsed_log_entry.dates) - author_data.push(parsed_log_entry) - ) - - _.sortBy(author_data, (d) -> - d[field] - ).reverse() - - parse_log_entry: (log_entry, field, date_range) -> - parsed_entry = {} - parsed_entry.author_name = log_entry.author_name - parsed_entry.author_email = log_entry.author_email - parsed_entry.dates = {} - parsed_entry.commits = parsed_entry.additions = parsed_entry.deletions = 0 - _.each(_.omit(log_entry, 'author_name', 'author_email'), (value, key) => - if @in_range(value.date, date_range) - parsed_entry.dates[value.date] = value[field] - parsed_entry.commits += value.commits - parsed_entry.additions += value.additions - parsed_entry.deletions += value.deletions - ) - return parsed_entry - - in_range: (date, date_range) -> - if date_range is null || date_range[0] <= new Date(date) <= date_range[1] - true - else - false diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/group_avatar.js new file mode 100644 index 00000000000..c28ce86d7af --- /dev/null +++ b/app/assets/javascripts/group_avatar.js @@ -0,0 +1,21 @@ +(function() { + this.GroupAvatar = (function() { + function GroupAvatar() { + $('.js-choose-group-avatar-button').bind("click", function() { + var form; + form = $(this).closest("form"); + return form.find(".js-group-avatar-input").click(); + }); + $('.js-group-avatar-input').bind("change", function() { + var filename, form; + form = $(this).closest("form"); + filename = $(this).val().replace(/^.*[\\\/]/, ''); + return form.find(".js-avatar-filename").text(filename); + }); + } + + return GroupAvatar; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/group_avatar.js.coffee b/app/assets/javascripts/group_avatar.js.coffee deleted file mode 100644 index 0825fd3ce52..00000000000 --- a/app/assets/javascripts/group_avatar.js.coffee +++ /dev/null @@ -1,9 +0,0 @@ -class @GroupAvatar - constructor: -> - $('.js-choose-group-avatar-button').bind "click", -> - form = $(this).closest("form") - form.find(".js-group-avatar-input").click() - $('.js-group-avatar-input').bind "change", -> - form = $(this).closest("form") - filename = $(this).val().replace(/^.*[\\\/]/, '') - form.find(".js-avatar-filename").text(filename) diff --git a/app/assets/javascripts/groups.js b/app/assets/javascripts/groups.js new file mode 100644 index 00000000000..4382dd6860f --- /dev/null +++ b/app/assets/javascripts/groups.js @@ -0,0 +1,13 @@ +(function() { + this.GroupMembers = (function() { + function GroupMembers() { + $('li.group_member').bind('ajax:success', function() { + return $(this).fadeOut(); + }); + } + + return GroupMembers; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/groups.js.coffee b/app/assets/javascripts/groups.js.coffee deleted file mode 100644 index cc905e91ea2..00000000000 --- a/app/assets/javascripts/groups.js.coffee +++ /dev/null @@ -1,4 +0,0 @@ -class @GroupMembers - constructor: -> - $('li.group_member').bind 'ajax:success', -> - $(this).fadeOut() diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js new file mode 100644 index 00000000000..fd5b6dc0ddd --- /dev/null +++ b/app/assets/javascripts/groups_select.js @@ -0,0 +1,67 @@ +(function() { + var slice = [].slice; + + this.GroupsSelect = (function() { + function GroupsSelect() { + $('.ajax-groups-select').each((function(_this) { + return function(i, select) { + var skip_ldap; + skip_ldap = $(select).hasClass('skip_ldap'); + return $(select).select2({ + placeholder: "Search for a group", + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return Api.groups(query.term, skip_ldap, function(groups) { + var data; + data = { + results: groups + }; + return query.callback(data); + }); + }, + initSelection: function(element, callback) { + var id; + id = $(element).val(); + if (id !== "") { + return Api.group(id, callback); + } + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: "ajax-groups-dropdown", + escapeMarkup: function(m) { + return m; + } + }); + }; + })(this)); + } + + GroupsSelect.prototype.formatResult = function(group) { + var avatar; + if (group.avatar_url) { + avatar = group.avatar_url; + } else { + avatar = gon.default_avatar_url; + } + return "<div class='group-result'> <div class='group-name'>" + group.name + "</div> <div class='group-path'>" + group.path + "</div> </div>"; + }; + + GroupsSelect.prototype.formatSelection = function(group) { + return group.name; + }; + + return GroupsSelect; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/groups_select.js.coffee b/app/assets/javascripts/groups_select.js.coffee deleted file mode 100644 index 1084e2a17d1..00000000000 --- a/app/assets/javascripts/groups_select.js.coffee +++ /dev/null @@ -1,41 +0,0 @@ -class @GroupsSelect - constructor: -> - $('.ajax-groups-select').each (i, select) => - skip_ldap = $(select).hasClass('skip_ldap') - - $(select).select2 - placeholder: "Search for a group" - multiple: $(select).hasClass('multiselect') - minimumInputLength: 0 - query: (query) -> - Api.groups query.term, skip_ldap, (groups) -> - data = { results: groups } - query.callback(data) - - initSelection: (element, callback) -> - id = $(element).val() - if id isnt "" - Api.group(id, callback) - - - formatResult: (args...) => - @formatResult(args...) - formatSelection: (args...) => - @formatSelection(args...) - dropdownCssClass: "ajax-groups-dropdown" - escapeMarkup: (m) -> # we do not want to escape markup since we are displaying html in results - m - - formatResult: (group) -> - if group.avatar_url - avatar = group.avatar_url - else - avatar = gon.default_avatar_url - - "<div class='group-result'> - <div class='group-name'>#{group.name}</div> - <div class='group-path'>#{group.path}</div> - </div>" - - formatSelection: (group) -> - group.name diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js new file mode 100644 index 00000000000..0f840821f53 --- /dev/null +++ b/app/assets/javascripts/importer_status.js @@ -0,0 +1,77 @@ +(function() { + this.ImporterStatus = (function() { + function ImporterStatus(jobs_url, import_url) { + this.jobs_url = jobs_url; + this.import_url = import_url; + this.initStatusPage(); + this.setAutoUpdate(); + } + + ImporterStatus.prototype.initStatusPage = function() { + $('.js-add-to-import').off('click').on('click', (function(_this) { + return function(e) { + var $btn, $namespace_input, $target_field, $tr, id, new_namespace; + $btn = $(e.currentTarget); + $tr = $btn.closest('tr'); + $target_field = $tr.find('.import-target'); + $namespace_input = $target_field.find('input'); + id = $tr.attr('id').replace('repo_', ''); + new_namespace = null; + if ($namespace_input.length > 0) { + new_namespace = $namespace_input.prop('value'); + $target_field.empty().append(new_namespace + "/" + ($target_field.data('project_name'))); + } + $btn.disable().addClass('is-loading'); + return $.post(_this.import_url, { + repo_id: id, + new_namespace: new_namespace + }, { + dataType: 'script' + }); + }; + })(this)); + return $('.js-import-all').off('click').on('click', function(e) { + var $btn; + $btn = $(this); + $btn.disable().addClass('is-loading'); + return $('.js-add-to-import').each(function() { + return $(this).trigger('click'); + }); + }); + }; + + ImporterStatus.prototype.setAutoUpdate = function() { + return setInterval(((function(_this) { + return function() { + return $.get(_this.jobs_url, function(data) { + return $.each(data, function(i, job) { + var job_item, status_field; + job_item = $("#project_" + job.id); + status_field = job_item.find(".job-status"); + if (job.import_status === 'finished') { + job_item.removeClass("active").addClass("success"); + return status_field.html('<span><i class="fa fa-check"></i> done</span>'); + } else if (job.import_status === 'started') { + return status_field.html("<i class='fa fa-spinner fa-spin'></i> started"); + } else { + return status_field.html(job.import_status); + } + }); + }); + }; + })(this)), 4000); + }; + + return ImporterStatus; + + })(); + + $(function() { + if ($('.js-importer-status').length) { + var jobsImportPath = $('.js-importer-status').data('jobs-import-path'); + var importPath = $('.js-importer-status').data('import-path'); + + new ImporterStatus(jobsImportPath, importPath); + } + }); +}).call(this); diff --git a/app/assets/javascripts/importer_status.js.coffee b/app/assets/javascripts/importer_status.js.coffee deleted file mode 100644 index eb046eb2eff..00000000000 --- a/app/assets/javascripts/importer_status.js.coffee +++ /dev/null @@ -1,53 +0,0 @@ -class @ImporterStatus - constructor: (@jobs_url, @import_url) -> - this.initStatusPage() - this.setAutoUpdate() - - initStatusPage: -> - $('.js-add-to-import') - .off 'click' - .on 'click', (e) => - $btn = $(e.currentTarget) - $tr = $btn.closest('tr') - $target_field = $tr.find('.import-target') - $namespace_input = $target_field.find('input') - id = $tr.attr('id').replace('repo_', '') - new_namespace = null - - if $namespace_input.length > 0 - new_namespace = $namespace_input.prop('value') - $target_field.empty().append("#{new_namespace}/#{$target_field.data('project_name')}") - - $btn - .disable() - .addClass 'is-loading' - - $.post @import_url, {repo_id: id, new_namespace: new_namespace}, dataType: 'script' - - $('.js-import-all') - .off 'click' - .on 'click', (e) -> - $btn = $(@) - $btn - .disable() - .addClass 'is-loading' - - $('.js-add-to-import').each -> - $(this).trigger('click') - - setAutoUpdate: -> - setInterval (=> - $.get @jobs_url, (data) => - $.each data, (i, job) => - job_item = $("#project_" + job.id) - status_field = job_item.find(".job-status") - - if job.import_status == 'finished' - job_item.removeClass("active").addClass("success") - status_field.html('<span><i class="fa fa-check"></i> done</span>') - else if job.import_status == 'started' - status_field.html("<i class='fa fa-spinner fa-spin'></i> started") - else - status_field.html(job.import_status) - - ), 4000 diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable.js new file mode 100644 index 00000000000..f27f1bad1f7 --- /dev/null +++ b/app/assets/javascripts/issuable.js @@ -0,0 +1,89 @@ +(function() { + var issuable_created; + + issuable_created = false; + + this.Issuable = { + init: function() { + if (!issuable_created) { + issuable_created = true; + Issuable.initTemplates(); + Issuable.initSearch(); + Issuable.initChecks(); + return Issuable.initLabelFilterRemove(); + } + }, + initTemplates: function() { + return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>'); + }, + initSearch: function() { + this.timer = null; + return $('#issue_search').off('keyup').on('keyup', function() { + clearTimeout(this.timer); + return this.timer = setTimeout(function() { + var $form, $input, $search; + $search = $('#issue_search'); + $form = $('.js-filter-form'); + $input = $("input[name='" + ($search.attr('name')) + "']", $form); + if ($input.length === 0) { + $form.append("<input type='hidden' name='" + ($search.attr('name')) + "' value='" + (_.escape($search.val())) + "'/>"); + } else { + $input.val($search.val()); + } + if ($search.val() !== '') { + return Issuable.filterResults($form); + } + }, 500); + }); + }, + initLabelFilterRemove: function() { + return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) { + var $button; + $button = $(this); + $('input[name="label_name[]"]').filter(function() { + return this.value === $button.data('label'); + }).remove(); + Issuable.filterResults($('.filter-form')); + return $('.js-label-select').trigger('update.label'); + }); + }, + filterResults: (function(_this) { + return function(form) { + var formAction, formData, issuesUrl; + formData = form.serialize(); + formAction = form.attr('action'); + issuesUrl = formAction; + issuesUrl += "" + (formAction.indexOf('?') < 0 ? '?' : '&'); + issuesUrl += formData; + return Turbolinks.visit(issuesUrl); + }; + })(this), + initChecks: function() { + this.issuableBulkActions = $('.bulk-update').data('bulkActions'); + $('.check_all_issues').off('click').on('click', function() { + $('.selected_issue').prop('checked', this.checked); + return Issuable.checkChanged(); + }); + return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this)); + }, + checkChanged: function() { + var checked_issues, ids; + checked_issues = $('.selected_issue:checked'); + if (checked_issues.length > 0) { + ids = $.map(checked_issues, function(value) { + return $(value).data('id'); + }); + $('#update_issues_ids').val(ids); + $('.issues-other-filters').hide(); + $('.issues_bulk_update').show(); + } else { + $('#update_issues_ids').val([]); + $('.issues_bulk_update').hide(); + $('.issues-other-filters').show(); + this.issuableBulkActions.willUpdateLabels = false; + } + return true; + } + }; + +}).call(this); diff --git a/app/assets/javascripts/issuable.js.coffee b/app/assets/javascripts/issuable.js.coffee deleted file mode 100644 index 7f795f8096b..00000000000 --- a/app/assets/javascripts/issuable.js.coffee +++ /dev/null @@ -1,93 +0,0 @@ -issuable_created = false -@Issuable = - init: -> - unless issuable_created - issuable_created = true - Issuable.initTemplates() - Issuable.initSearch() - Issuable.initChecks() - Issuable.initLabelFilterRemove() - - initTemplates: -> - Issuable.labelRow = _.template( - '<% _.each(labels, function(label){ %> - <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> - <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> - <%- label.title %> - </a> - <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> - <i class="fa fa-times"></i> - </button> - </span> - <% }); %>' - ) - - initSearch: -> - @timer = null - $('#issue_search') - .off 'keyup' - .on 'keyup', -> - clearTimeout(@timer) - @timer = setTimeout( -> - $search = $('#issue_search') - $form = $('.js-filter-form') - $input = $("input[name='#{$search.attr('name')}']", $form) - if $input.length is 0 - $form.append "<input type='hidden' name='#{$search.attr('name')}' value='#{_.escape($search.val())}'/>" - else - $input.val $search.val() - Issuable.filterResults $form if $search.val() isnt '' - , 500) - - initLabelFilterRemove: -> - $(document) - .off 'click', '.js-label-filter-remove' - .on 'click', '.js-label-filter-remove', (e) -> - $button = $(@) - - # Remove the label input box - $('input[name="label_name[]"]') - .filter -> @value is $button.data('label') - .remove() - - # Submit the form to get new data - Issuable.filterResults $('.filter-form') - $('.js-label-select').trigger('update.label') - - filterResults: (form) => - formData = form.serialize() - - formAction = form.attr('action') - issuesUrl = formAction - issuesUrl += ("#{if formAction.indexOf('?') < 0 then '?' else '&'}") - issuesUrl += formData - - Turbolinks.visit(issuesUrl) - - initChecks: -> - @issuableBulkActions = $('.bulk-update').data('bulkActions') - - $('.check_all_issues').off('click').on('click', -> - $('.selected_issue').prop('checked', @checked) - Issuable.checkChanged() - ) - - $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(@)) - - - checkChanged: -> - checked_issues = $('.selected_issue:checked') - if checked_issues.length > 0 - ids = $.map checked_issues, (value) -> - $(value).data('id') - - $('#update_issues_ids').val ids - $('.issues-other-filters').hide() - $('.issues_bulk_update').show() - else - $('#update_issues_ids').val [] - $('.issues_bulk_update').hide() - $('.issues-other-filters').show() - @issuableBulkActions.willUpdateLabels = false - - return true diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js new file mode 100644 index 00000000000..8147e83ffe8 --- /dev/null +++ b/app/assets/javascripts/issuable_context.js @@ -0,0 +1,69 @@ +(function() { + this.IssuableContext = (function() { + function IssuableContext(currentUser) { + this.initParticipants(); + new UsersSelect(currentUser); + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true + }); + $(".issuable-sidebar .inline-update").on("change", "select", function() { + return $(this).submit(); + }); + $(".issuable-sidebar .inline-update").on("change", ".js-assignee", function() { + return $(this).submit(); + }); + $(document).off('click', '.issuable-sidebar .dropdown-content a').on('click', '.issuable-sidebar .dropdown-content a', function(e) { + return e.preventDefault(); + }); + $(document).off('click', '.edit-link').on('click', '.edit-link', function(e) { + var $block, $selectbox; + e.preventDefault(); + $block = $(this).parents('.block'); + $selectbox = $block.find('.selectbox'); + if ($selectbox.is(':visible')) { + $selectbox.hide(); + $block.find('.value').show(); + } else { + $selectbox.show(); + $block.find('.value').hide(); + } + if ($selectbox.is(':visible')) { + return setTimeout(function() { + return $block.find('.dropdown-menu-toggle').trigger('click'); + }, 0); + } + }); + $(".right-sidebar").niceScroll(); + } + + IssuableContext.prototype.initParticipants = function() { + var _this; + _this = this; + $(document).on("click", ".js-participants-more", this.toggleHiddenParticipants); + return $(".js-participants-author").each(function(i) { + if (i >= _this.PARTICIPANTS_ROW_COUNT) { + return $(this).addClass("js-participants-hidden").hide(); + } + }); + }; + + IssuableContext.prototype.toggleHiddenParticipants = function(e) { + var currentText, lessText, originalText; + e.preventDefault(); + currentText = $(this).text().trim(); + lessText = $(this).data("less-text"); + originalText = $(this).data("original-text"); + if (currentText === originalText) { + $(this).text(lessText); + } else { + $(this).text(originalText); + } + return $(".js-participants-hidden").toggle(); + }; + + return IssuableContext; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/issuable_context.js.coffee b/app/assets/javascripts/issuable_context.js.coffee deleted file mode 100644 index 3c491ebfc4c..00000000000 --- a/app/assets/javascripts/issuable_context.js.coffee +++ /dev/null @@ -1,60 +0,0 @@ -class @IssuableContext - constructor: (currentUser) -> - @initParticipants() - new UsersSelect(currentUser) - $('select.select2').select2({width: 'resolve', dropdownAutoWidth: true}) - - $(".issuable-sidebar .inline-update").on "change", "select", -> - $(this).submit() - $(".issuable-sidebar .inline-update").on "change", ".js-assignee", -> - $(this).submit() - - $(document) - .off 'click', '.issuable-sidebar .dropdown-content a' - .on 'click', '.issuable-sidebar .dropdown-content a', (e) -> - e.preventDefault() - - $(document) - .off 'click', '.edit-link' - .on 'click', '.edit-link', (e) -> - e.preventDefault() - - $block = $(@).parents('.block') - $selectbox = $block.find('.selectbox') - if $selectbox.is(':visible') - $selectbox.hide() - $block.find('.value').show() - else - $selectbox.show() - $block.find('.value').hide() - - if $selectbox.is(':visible') - setTimeout -> - $block.find('.dropdown-menu-toggle').trigger 'click' - , 0 - - $(".right-sidebar").niceScroll() - - initParticipants: -> - _this = @ - $(document).on "click", ".js-participants-more", @toggleHiddenParticipants - - $(".js-participants-author").each (i) -> - if i >= _this.PARTICIPANTS_ROW_COUNT - $(@) - .addClass "js-participants-hidden" - .hide() - - toggleHiddenParticipants: (e) -> - e.preventDefault() - - currentText = $(this).text().trim() - lessText = $(this).data("less-text") - originalText = $(this).data("original-text") - - if currentText is originalText - $(this).text(lessText) - else - $(this).text(originalText) - - $(".js-participants-hidden").toggle() diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js new file mode 100644 index 00000000000..297d4f029f0 --- /dev/null +++ b/app/assets/javascripts/issuable_form.js @@ -0,0 +1,136 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.IssuableForm = (function() { + IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; + + IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i; + + function IssuableForm(form) { + var $issuableDueDate; + this.form = form; + this.toggleWip = bind(this.toggleWip, this); + this.renderWipExplanation = bind(this.renderWipExplanation, this); + this.resetAutosave = bind(this.resetAutosave, this); + this.handleSubmit = bind(this.handleSubmit, this); + GitLab.GfmAutoComplete.setup(); + new UsersSelect(); + new ZenMode(); + this.titleField = this.form.find("input[name*='[title]']"); + this.descriptionField = this.form.find("textarea[name*='[description]']"); + this.issueMoveField = this.form.find("#move_to_project_id"); + if (!(this.titleField.length && this.descriptionField.length)) { + return; + } + this.initAutosave(); + this.form.on("submit", this.handleSubmit); + this.form.on("click", ".btn-cancel", this.resetAutosave); + this.initWip(); + this.initMoveDropdown(); + $issuableDueDate = $('#issuable-due-date'); + if ($issuableDueDate.length) { + $('.datepicker').datepicker({ + dateFormat: 'yy-mm-dd', + onSelect: function(dateText, inst) { + return $issuableDueDate.val(dateText); + } + }).datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $issuableDueDate.val())); + } + } + + IssuableForm.prototype.initAutosave = function() { + new Autosave(this.titleField, [document.location.pathname, document.location.search, "title"]); + return new Autosave(this.descriptionField, [document.location.pathname, document.location.search, "description"]); + }; + + IssuableForm.prototype.handleSubmit = function() { + var ref, ref1; + if (((ref = parseInt((ref1 = this.issueMoveField) != null ? ref1.val() : void 0)) != null ? ref : 0) > 0) { + if (!confirm(this.issueMoveConfirmMsg)) { + return false; + } + } + return this.resetAutosave(); + }; + + IssuableForm.prototype.resetAutosave = function() { + this.titleField.data("autosave").reset(); + return this.descriptionField.data("autosave").reset(); + }; + + IssuableForm.prototype.initWip = function() { + this.$wipExplanation = this.form.find(".js-wip-explanation"); + this.$noWipExplanation = this.form.find(".js-no-wip-explanation"); + if (!(this.$wipExplanation.length && this.$noWipExplanation.length)) { + return; + } + this.form.on("click", ".js-toggle-wip", this.toggleWip); + this.titleField.on("keyup blur", this.renderWipExplanation); + return this.renderWipExplanation(); + }; + + IssuableForm.prototype.workInProgress = function() { + return this.wipRegex.test(this.titleField.val()); + }; + + IssuableForm.prototype.renderWipExplanation = function() { + if (this.workInProgress()) { + this.$wipExplanation.show(); + return this.$noWipExplanation.hide(); + } else { + this.$wipExplanation.hide(); + return this.$noWipExplanation.show(); + } + }; + + IssuableForm.prototype.toggleWip = function(event) { + event.preventDefault(); + if (this.workInProgress()) { + this.removeWip(); + } else { + this.addWip(); + } + return this.renderWipExplanation(); + }; + + IssuableForm.prototype.removeWip = function() { + return this.titleField.val(this.titleField.val().replace(this.wipRegex, "")); + }; + + IssuableForm.prototype.addWip = function() { + return this.titleField.val("WIP: " + (this.titleField.val())); + }; + + IssuableForm.prototype.initMoveDropdown = function() { + var $moveDropdown; + $moveDropdown = $('.js-move-dropdown'); + if ($moveDropdown.length) { + return $('.js-move-dropdown').select2({ + ajax: { + url: $moveDropdown.data('projects-url'), + results: function(data) { + return { + results: data + }; + }, + data: function(query) { + return { + search: query + }; + } + }, + formatResult: function(project) { + return project.name_with_namespace; + }, + formatSelection: function(project) { + return project.name_with_namespace; + } + }); + } + }; + + return IssuableForm; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/issuable_form.js.coffee b/app/assets/javascripts/issuable_form.js.coffee deleted file mode 100644 index 5b7a4831dfc..00000000000 --- a/app/assets/javascripts/issuable_form.js.coffee +++ /dev/null @@ -1,112 +0,0 @@ -class @IssuableForm - issueMoveConfirmMsg: 'Are you sure you want to move this issue to another project?' - wipRegex: /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i - - constructor: (@form) -> - GitLab.GfmAutoComplete.setup() - new UsersSelect() - new ZenMode() - - @titleField = @form.find("input[name*='[title]']") - @descriptionField = @form.find("textarea[name*='[description]']") - @issueMoveField = @form.find("#move_to_project_id") - - return unless @titleField.length && @descriptionField.length - - @initAutosave() - - @form.on "submit", @handleSubmit - @form.on "click", ".btn-cancel", @resetAutosave - - @initWip() - @initMoveDropdown() - - $issuableDueDate = $('#issuable-due-date') - - if $issuableDueDate.length - $('.datepicker').datepicker( - dateFormat: 'yy-mm-dd', - onSelect: (dateText, inst) -> - $issuableDueDate.val dateText - ).datepicker 'setDate', $.datepicker.parseDate('yy-mm-dd', $issuableDueDate.val()) - - initAutosave: -> - new Autosave @titleField, [ - document.location.pathname, - document.location.search, - "title" - ] - - new Autosave @descriptionField, [ - document.location.pathname, - document.location.search, - "description" - ] - - handleSubmit: => - if (parseInt(@issueMoveField?.val()) ? 0) > 0 - return false unless confirm(@issueMoveConfirmMsg) - - @resetAutosave() - - resetAutosave: => - @titleField.data("autosave").reset() - @descriptionField.data("autosave").reset() - - initWip: -> - @$wipExplanation = @form.find(".js-wip-explanation") - @$noWipExplanation = @form.find(".js-no-wip-explanation") - return unless @$wipExplanation.length and @$noWipExplanation.length - - @form.on "click", ".js-toggle-wip", @toggleWip - - @titleField.on "keyup blur", @renderWipExplanation - - @renderWipExplanation() - - workInProgress: -> - @wipRegex.test @titleField.val() - - renderWipExplanation: => - if @workInProgress() - @$wipExplanation.show() - @$noWipExplanation.hide() - else - @$wipExplanation.hide() - @$noWipExplanation.show() - - toggleWip: (event) => - event.preventDefault() - - if @workInProgress() - @removeWip() - else - @addWip() - - @renderWipExplanation() - - removeWip: -> - @titleField.val @titleField.val().replace(@wipRegex, "") - - addWip: -> - @titleField.val "WIP: #{@titleField.val()}" - - initMoveDropdown: -> - $moveDropdown = $('.js-move-dropdown') - - if $moveDropdown.length - $('.js-move-dropdown').select2 - ajax: - url: $moveDropdown.data('projects-url') - results: (data) -> - return { - results: data - } - data: (query) -> - { - search: query - } - formatResult: (project) -> - project.name_with_namespace - formatSelection: (project) -> - project.name_with_namespace diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js new file mode 100644 index 00000000000..6838d9d8da1 --- /dev/null +++ b/app/assets/javascripts/issue.js @@ -0,0 +1,154 @@ + +/*= require flash */ + + +/*= require jquery.waitforimages */ + + +/*= require task_list */ + +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Issue = (function() { + function Issue() { + this.submitNoteForm = bind(this.submitNoteForm, this); + this.disableTaskList(); + if ($('a.btn-close').length) { + this.initTaskList(); + this.initIssueBtnEventListeners(); + } + this.initMergeRequests(); + this.initRelatedBranches(); + this.initCanCreateBranch(); + } + + Issue.prototype.initTaskList = function() { + $('.detail-page-description .js-task-list-container').taskList('enable'); + return $(document).on('tasklist:changed', '.detail-page-description .js-task-list-container', this.updateTaskList); + }; + + Issue.prototype.initIssueBtnEventListeners = function() { + var _this, issueFailMessage; + _this = this; + issueFailMessage = 'Unable to update this issue at this time.'; + return $('a.btn-close, a.btn-reopen').on('click', function(e) { + var $this, isClose, shouldSubmit, url; + e.preventDefault(); + e.stopImmediatePropagation(); + $this = $(this); + isClose = $this.hasClass('btn-close'); + shouldSubmit = $this.hasClass('btn-comment'); + if (shouldSubmit) { + _this.submitNoteForm($this.closest('form')); + } + $this.prop('disabled', true); + url = $this.attr('href'); + return $.ajax({ + type: 'PUT', + url: url, + error: function(jqXHR, textStatus, errorThrown) { + var issueStatus; + issueStatus = isClose ? 'close' : 'open'; + return new Flash(issueFailMessage, 'alert'); + }, + success: function(data, textStatus, jqXHR) { + if ('id' in data) { + $(document).trigger('issuable:change'); + if (isClose) { + $('a.btn-close').addClass('hidden'); + $('a.btn-reopen').removeClass('hidden'); + $('div.status-box-closed').removeClass('hidden'); + $('div.status-box-open').addClass('hidden'); + } else { + $('a.btn-reopen').addClass('hidden'); + $('a.btn-close').removeClass('hidden'); + $('div.status-box-closed').addClass('hidden'); + $('div.status-box-open').removeClass('hidden'); + } + } else { + new Flash(issueFailMessage, 'alert'); + } + return $this.prop('disabled', false); + } + }); + }); + }; + + Issue.prototype.submitNoteForm = function(form) { + var noteText; + noteText = form.find("textarea.js-note-text").val(); + if (noteText.trim().length > 0) { + return form.submit(); + } + }; + + Issue.prototype.disableTaskList = function() { + $('.detail-page-description .js-task-list-container').taskList('disable'); + return $(document).off('tasklist:changed', '.detail-page-description .js-task-list-container'); + }; + + Issue.prototype.updateTaskList = function() { + var patchData; + patchData = {}; + patchData['issue'] = { + 'description': $('.js-task-list-field', this).val() + }; + return $.ajax({ + type: 'PATCH', + url: $('form.js-issuable-update').attr('action'), + data: patchData + }); + }; + + Issue.prototype.initMergeRequests = function() { + var $container; + $container = $('#merge-requests'); + return $.getJSON($container.data('url')).error(function() { + return new Flash('Failed to load referenced merge requests', 'alert'); + }).success(function(data) { + if ('html' in data) { + return $container.html(data.html); + } + }); + }; + + Issue.prototype.initRelatedBranches = function() { + var $container; + $container = $('#related-branches'); + return $.getJSON($container.data('url')).error(function() { + return new Flash('Failed to load related branches', 'alert'); + }).success(function(data) { + if ('html' in data) { + return $container.html(data.html); + } + }); + }; + + Issue.prototype.initCanCreateBranch = function() { + var $container; + $container = $('div#new-branch'); + if ($container.length === 0) { + return; + } + return $.getJSON($container.data('path')).error(function() { + $container.find('.checking').hide(); + $container.find('.unavailable').show(); + return new Flash('Failed to check if a new branch can be created.', 'alert'); + }).success(function(data) { + if (data.can_create_branch) { + $container.find('.checking').hide(); + $container.find('.available').show(); + return $container.find('a').attr('disabled', false); + } else { + $container.find('.checking').hide(); + return $container.find('.unavailable').show(); + } + }); + }; + + return Issue; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/issue.js.coffee b/app/assets/javascripts/issue.js.coffee deleted file mode 100644 index f446aa49cde..00000000000 --- a/app/assets/javascripts/issue.js.coffee +++ /dev/null @@ -1,117 +0,0 @@ -#= require flash -#= require jquery.waitforimages -#= require task_list - -class @Issue - constructor: -> - # Prevent duplicate event bindings - @disableTaskList() - if $('a.btn-close').length - @initTaskList() - @initIssueBtnEventListeners() - - @initMergeRequests() - @initRelatedBranches() - @initCanCreateBranch() - - initTaskList: -> - $('.detail-page-description .js-task-list-container').taskList('enable') - $(document).on 'tasklist:changed', '.detail-page-description .js-task-list-container', @updateTaskList - - initIssueBtnEventListeners: -> - _this = @ - issueFailMessage = 'Unable to update this issue at this time.' - $('a.btn-close, a.btn-reopen').on 'click', (e) -> - e.preventDefault() - e.stopImmediatePropagation() - $this = $(this) - isClose = $this.hasClass('btn-close') - shouldSubmit = $this.hasClass('btn-comment') - if shouldSubmit - _this.submitNoteForm($this.closest('form')) - $this.prop('disabled', true) - url = $this.attr('href') - $.ajax - type: 'PUT' - url: url, - error: (jqXHR, textStatus, errorThrown) -> - issueStatus = if isClose then 'close' else 'open' - new Flash(issueFailMessage, 'alert') - success: (data, textStatus, jqXHR) -> - if 'id' of data - $(document).trigger('issuable:change'); - if isClose - $('a.btn-close').addClass('hidden') - $('a.btn-reopen').removeClass('hidden') - $('div.status-box-closed').removeClass('hidden') - $('div.status-box-open').addClass('hidden') - else - $('a.btn-reopen').addClass('hidden') - $('a.btn-close').removeClass('hidden') - $('div.status-box-closed').addClass('hidden') - $('div.status-box-open').removeClass('hidden') - else - new Flash(issueFailMessage, 'alert') - $this.prop('disabled', false) - - submitNoteForm: (form) => - noteText = form.find("textarea.js-note-text").val() - if noteText.trim().length > 0 - form.submit() - - disableTaskList: -> - $('.detail-page-description .js-task-list-container').taskList('disable') - $(document).off 'tasklist:changed', '.detail-page-description .js-task-list-container' - - # TODO (rspeicher): Make the issue description inline-editable like a note so - # that we can re-use its form here - updateTaskList: -> - patchData = {} - patchData['issue'] = {'description': $('.js-task-list-field', this).val()} - - $.ajax - type: 'PATCH' - url: $('form.js-issuable-update').attr('action') - data: patchData - - initMergeRequests: -> - $container = $('#merge-requests') - - $.getJSON($container.data('url')) - .error -> - new Flash('Failed to load referenced merge requests', 'alert') - .success (data) -> - if 'html' of data - $container.html(data.html) - - initRelatedBranches: -> - $container = $('#related-branches') - - $.getJSON($container.data('url')) - .error -> - new Flash('Failed to load related branches', 'alert') - .success (data) -> - if 'html' of data - $container.html(data.html) - - initCanCreateBranch: -> - $container = $('div#new-branch') - - # If the user doesn't have the required permissions the container isn't - # rendered at all. - return if $container.length is 0 - - $.getJSON($container.data('path')) - .error -> - $container.find('.checking').hide() - $container.find('.unavailable').show() - - new Flash('Failed to check if a new branch can be created.', 'alert') - .success (data) -> - if data.can_create_branch - $container.find('.checking').hide() - $container.find('.available').show() - $container.find('a').attr('disabled', false) - else - $container.find('.checking').hide() - $container.find('.unavailable').show() diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js new file mode 100644 index 00000000000..076e3972944 --- /dev/null +++ b/app/assets/javascripts/issue_status_select.js @@ -0,0 +1,35 @@ +(function() { + this.IssueStatusSelect = (function() { + function IssueStatusSelect() { + $('.js-issue-status').each(function(i, el) { + var fieldName; + fieldName = $(el).data("field-name"); + return $(el).glDropdown({ + selectable: true, + fieldName: fieldName, + toggleLabel: (function(_this) { + return function(selected, el, instance) { + var $item, label; + label = 'Author'; + $item = instance.dropdown.find('.is-active'); + if ($item.length) { + label = $item.text(); + } + return label; + }; + })(this), + clicked: function(item, $el, e) { + return e.preventDefault(); + }, + id: function(obj, el) { + return $(el).data("id"); + } + }); + }); + } + + return IssueStatusSelect; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/issue_status_select.js.coffee b/app/assets/javascripts/issue_status_select.js.coffee deleted file mode 100644 index ed50e2e698f..00000000000 --- a/app/assets/javascripts/issue_status_select.js.coffee +++ /dev/null @@ -1,18 +0,0 @@ -class @IssueStatusSelect - constructor: -> - $('.js-issue-status').each (i, el) -> - fieldName = $(el).data("field-name") - - $(el).glDropdown( - selectable: true - fieldName: fieldName - toggleLabel: (selected, el, instance) => - label = 'Author' - $item = instance.dropdown.find('.is-active') - label = $item.text() if $item.length - label - clicked: (item, $el, e)-> - e.preventDefault() - id: (obj, el) -> - $(el).data("id") - ) diff --git a/app/assets/javascripts/issues-bulk-assignment.js b/app/assets/javascripts/issues-bulk-assignment.js new file mode 100644 index 00000000000..98d3358ba92 --- /dev/null +++ b/app/assets/javascripts/issues-bulk-assignment.js @@ -0,0 +1,161 @@ +(function() { + this.IssuableBulkActions = (function() { + function IssuableBulkActions(opts) { + var ref, ref1, ref2; + if (opts == null) { + opts = {}; + } + this.container = (ref = opts.container) != null ? ref : $('.content'), this.form = (ref1 = opts.form) != null ? ref1 : this.getElement('.bulk-update'), this.issues = (ref2 = opts.issues) != null ? ref2 : this.getElement('.issues-list .issue'); + this.form.data('bulkActions', this); + this.willUpdateLabels = false; + this.bindEvents(); + Issuable.initChecks(); + } + + IssuableBulkActions.prototype.getElement = function(selector) { + return this.container.find(selector); + }; + + IssuableBulkActions.prototype.bindEvents = function() { + return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); + }; + + IssuableBulkActions.prototype.onFormSubmit = function(e) { + e.preventDefault(); + return this.submit(); + }; + + IssuableBulkActions.prototype.submit = function() { + var _this, xhr; + _this = this; + xhr = $.ajax({ + url: this.form.attr('action'), + method: this.form.attr('method'), + dataType: 'JSON', + data: this.getFormDataAsObject() + }); + xhr.done(function(response, status, xhr) { + return location.reload(); + }); + xhr.fail(function() { + return new Flash("Issue update failed"); + }); + return xhr.always(this.onFormSubmitAlways.bind(this)); + }; + + IssuableBulkActions.prototype.onFormSubmitAlways = function() { + return this.form.find('[type="submit"]').enable(); + }; + + IssuableBulkActions.prototype.getSelectedIssues = function() { + return this.issues.has('.selected_issue:checked'); + }; + + IssuableBulkActions.prototype.getLabelsFromSelection = function() { + var labels; + labels = []; + this.getSelectedIssues().map(function() { + var _labels; + _labels = $(this).data('labels'); + if (_labels) { + return _labels.map(function(labelId) { + if (labels.indexOf(labelId) === -1) { + return labels.push(labelId); + } + }); + } + }); + return labels; + }; + + + /** + * Will return only labels that were marked previously and the user has unmarked + * @return {Array} Label IDs + */ + + IssuableBulkActions.prototype.getUnmarkedIndeterminedLabels = function() { + var el, i, id, j, labelsToKeep, len, len1, ref, ref1, result; + result = []; + labelsToKeep = []; + ref = this.getElement('.labels-filter .is-indeterminate'); + for (i = 0, len = ref.length; i < len; i++) { + el = ref[i]; + labelsToKeep.push($(el).data('labelId')); + } + ref1 = this.getLabelsFromSelection(); + for (j = 0, len1 = ref1.length; j < len1; j++) { + id = ref1[j]; + if (labelsToKeep.indexOf(id) === -1) { + result.push(id); + } + } + return result; + }; + + + /** + * Simple form serialization, it will return just what we need + * Returns key/value pairs from form data + */ + + IssuableBulkActions.prototype.getFormDataAsObject = function() { + var formData; + formData = { + update: { + state_event: this.form.find('input[name="update[state_event]"]').val(), + assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), + issues_ids: this.form.find('input[name="update[issues_ids]"]').val(), + subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), + add_label_ids: [], + remove_label_ids: [] + } + }; + if (this.willUpdateLabels) { + this.getLabelsToApply().map(function(id) { + return formData.update.add_label_ids.push(id); + }); + this.getLabelsToRemove().map(function(id) { + return formData.update.remove_label_ids.push(id); + }); + } + return formData; + }; + + IssuableBulkActions.prototype.getLabelsToApply = function() { + var $labels, labelIds; + labelIds = []; + $labels = this.form.find('.labels-filter input[name="update[label_ids][]"]'); + $labels.each(function(k, label) { + if (label) { + return labelIds.push(parseInt($(label).val())); + } + }); + return labelIds; + }; + + + /** + * Returns Label IDs that will be removed from issue selection + * @return {Array} Array of labels IDs + */ + + IssuableBulkActions.prototype.getLabelsToRemove = function() { + var indeterminatedLabels, labelsToApply, result; + result = []; + indeterminatedLabels = this.getUnmarkedIndeterminedLabels(); + labelsToApply = this.getLabelsToApply(); + indeterminatedLabels.map(function(id) { + if (labelsToApply.indexOf(id) === -1) { + return result.push(id); + } + }); + return result; + }; + + return IssuableBulkActions; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/issues-bulk-assignment.js.coffee b/app/assets/javascripts/issues-bulk-assignment.js.coffee deleted file mode 100644 index 3d09ea08e3b..00000000000 --- a/app/assets/javascripts/issues-bulk-assignment.js.coffee +++ /dev/null @@ -1,128 +0,0 @@ -class @IssuableBulkActions - constructor: (opts = {}) -> - # Set defaults - { - @container = $('.content') - @form = @getElement('.bulk-update') - @issues = @getElement('.issues-list .issue') - } = opts - - # Save instance - @form.data 'bulkActions', @ - - @willUpdateLabels = false - - @bindEvents() - - # Fixes bulk-assign not working when navigating through pages - Issuable.initChecks(); - - getElement: (selector) -> - @container.find selector - - bindEvents: -> - @form.off('submit').on('submit', @onFormSubmit.bind(@)) - - onFormSubmit: (e) -> - e.preventDefault() - @submit() - - submit: -> - _this = @ - - xhr = $.ajax - url: @form.attr 'action' - method: @form.attr 'method' - dataType: 'JSON', - data: @getFormDataAsObject() - - xhr.done (response, status, xhr) -> - location.reload() - - xhr.fail -> - new Flash("Issue update failed") - - xhr.always @onFormSubmitAlways.bind(@) - - onFormSubmitAlways: -> - @form.find('[type="submit"]').enable() - - getSelectedIssues: -> - @issues.has('.selected_issue:checked') - - getLabelsFromSelection: -> - labels = [] - - @getSelectedIssues().map -> - _labels = $(@).data('labels') - if _labels - _labels.map (labelId) -> - labels.push(labelId) if labels.indexOf(labelId) is -1 - - labels - - ###* - * Will return only labels that were marked previously and the user has unmarked - * @return {Array} Label IDs - ### - getUnmarkedIndeterminedLabels: -> - result = [] - labelsToKeep = [] - - for el in @getElement('.labels-filter .is-indeterminate') - labelsToKeep.push $(el).data('labelId') - - for id in @getLabelsFromSelection() - # Only the ones that we are not going to keep - result.push(id) if labelsToKeep.indexOf(id) is -1 - - result - - ###* - * Simple form serialization, it will return just what we need - * Returns key/value pairs from form data - ### - getFormDataAsObject: -> - formData = - update: - state_event : @form.find('input[name="update[state_event]"]').val() - assignee_id : @form.find('input[name="update[assignee_id]"]').val() - milestone_id : @form.find('input[name="update[milestone_id]"]').val() - issues_ids : @form.find('input[name="update[issues_ids]"]').val() - subscription_event : @form.find('input[name="update[subscription_event]"]').val() - add_label_ids : [] - remove_label_ids : [] - - if @willUpdateLabels - @getLabelsToApply().map (id) -> - formData.update.add_label_ids.push id - - @getLabelsToRemove().map (id) -> - formData.update.remove_label_ids.push id - - formData - - getLabelsToApply: -> - labelIds = [] - $labels = @form.find('.labels-filter input[name="update[label_ids][]"]') - - $labels.each (k, label) -> - labelIds.push parseInt($(label).val()) if label - - labelIds - - ###* - * Returns Label IDs that will be removed from issue selection - * @return {Array} Array of labels IDs - ### - getLabelsToRemove: -> - result = [] - indeterminatedLabels = @getUnmarkedIndeterminedLabels() - labelsToApply = @getLabelsToApply() - - indeterminatedLabels.map (id) -> - # We need to exclude label IDs that will be applied - # By not doing this will cause issues from selection to not add labels at all - result.push(id) if labelsToApply.indexOf(id) is -1 - - result diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js new file mode 100644 index 00000000000..fe071fca67c --- /dev/null +++ b/app/assets/javascripts/labels.js @@ -0,0 +1,44 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Labels = (function() { + function Labels() { + this.setSuggestedColor = bind(this.setSuggestedColor, this); + this.updateColorPreview = bind(this.updateColorPreview, this); + var form; + form = $('.label-form'); + this.cleanBinding(); + this.addBinding(); + this.updateColorPreview(); + } + + Labels.prototype.addBinding = function() { + $(document).on('click', '.suggest-colors a', this.setSuggestedColor); + return $(document).on('input', 'input#label_color', this.updateColorPreview); + }; + + Labels.prototype.cleanBinding = function() { + $(document).off('click', '.suggest-colors a'); + return $(document).off('input', 'input#label_color'); + }; + + Labels.prototype.updateColorPreview = function() { + var previewColor; + previewColor = $('input#label_color').val(); + return $('div.label-color-preview').css('background-color', previewColor); + }; + + Labels.prototype.setSuggestedColor = function(e) { + var color; + color = $(e.currentTarget).data('color'); + $('input#label_color').val(color); + this.updateColorPreview(); + $('.label-form').trigger('keyup'); + return e.preventDefault(); + }; + + return Labels; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/labels.js.coffee b/app/assets/javascripts/labels.js.coffee deleted file mode 100644 index d05bacd7494..00000000000 --- a/app/assets/javascripts/labels.js.coffee +++ /dev/null @@ -1,28 +0,0 @@ -class @Labels - constructor: -> - form = $('.label-form') - @cleanBinding() - @addBinding() - @updateColorPreview() - - addBinding: -> - $(document).on 'click', '.suggest-colors a', @setSuggestedColor - $(document).on 'input', 'input#label_color', @updateColorPreview - - cleanBinding: -> - $(document).off 'click', '.suggest-colors a' - $(document).off 'input', 'input#label_color' - - # Updates the the preview color with the hex-color input - updateColorPreview: => - previewColor = $('input#label_color').val() - $('div.label-color-preview').css('background-color', previewColor) - - # Updates the preview color with a click on a suggested color - setSuggestedColor: (e) => - color = $(e.currentTarget).data('color') - $('input#label_color').val(color) - @updateColorPreview() - # Notify the form, that color has changed - $('.label-form').trigger('keyup') - e.preventDefault() diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js new file mode 100644 index 00000000000..675dd5b7cea --- /dev/null +++ b/app/assets/javascripts/labels_select.js @@ -0,0 +1,377 @@ +(function() { + this.LabelsSelect = (function() { + function LabelsSelect() { + var _this; + _this = this; + $('.js-label-select').each(function(i, dropdown) { + var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo; + $dropdown = $(dropdown); + projectId = $dropdown.data('project-id'); + labelUrl = $dropdown.data('labels'); + issueUpdateURL = $dropdown.data('issueUpdate'); + selectedLabel = $dropdown.data('selected'); + if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) { + selectedLabel = selectedLabel.split(','); + } + newLabelField = $('#new_label_name'); + newColorField = $('#new_label_color'); + showNo = $dropdown.data('show-no'); + showAny = $dropdown.data('show-any'); + defaultLabel = $dropdown.data('default-label'); + abilityName = $dropdown.data('ability-name'); + $selectbox = $dropdown.closest('.selectbox'); + $block = $selectbox.closest('.block'); + $form = $dropdown.closest('form'); + $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); + $value = $block.find('.value'); + $newLabelError = $('.js-label-error'); + $colorPreview = $('.js-dropdown-label-color-preview'); + $newLabelCreateButton = $('.js-new-label-btn'); + $newLabelError.hide(); + $loading = $block.find('.block-loading').fadeOut(); + if (issueUpdateURL != null) { + issueURLSplit = issueUpdateURL.split('/'); + } + if (issueUpdateURL) { + labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); + labelNoneHTMLTemplate = '<span class="no-value">None</span>'; + } + if (newLabelField.length) { + $('.suggest-colors-dropdown a').on("click", function(e) { + e.preventDefault(); + e.stopPropagation(); + newColorField.val($(this).data('color')).trigger('change'); + return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active'); + }); + resetForm = function() { + newLabelField.val('').trigger('change'); + newColorField.val('').trigger('change'); + return $colorPreview.css('background-color', '').parent().removeClass('is-active'); + }; + $('.dropdown-menu-back').on('click', function() { + return resetForm(); + }); + $('.js-cancel-label-btn').on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + resetForm(); + return $('.dropdown-menu-back', $dropdown.parent()).trigger('click'); + }); + enableLabelCreateButton = function() { + if (newLabelField.val() !== '' && newColorField.val() !== '') { + $newLabelError.hide(); + return $newLabelCreateButton.enable(); + } else { + return $newLabelCreateButton.disable(); + } + }; + saveLabel = function() { + return Api.newLabel(projectId, { + name: newLabelField.val(), + color: newColorField.val() + }, function(label) { + var errors; + $newLabelCreateButton.enable(); + if (label.message != null) { + errors = _.map(label.message, function(value, key) { + return key + " " + value[0]; + }); + return $newLabelError.html(errors.join("<br/>")).show(); + } else { + return $('.dropdown-menu-back', $dropdown.parent()).trigger('click'); + } + }); + }; + newLabelField.on('keyup change', enableLabelCreateButton); + newColorField.on('keyup change', enableLabelCreateButton); + $newLabelCreateButton.disable().on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + return saveLabel(); + }); + } + saveLabelData = function() { + var data, selected; + selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() { + return this.value; + }).get(); + data = {}; + data[abilityName] = {}; + data[abilityName].label_ids = selected; + if (!selected.length) { + data[abilityName].label_ids = ['']; + } + $loading.fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + return $.ajax({ + type: 'PUT', + url: issueUpdateURL, + dataType: 'JSON', + data: data + }).done(function(data) { + var labelCount, template; + $loading.fadeOut(); + $dropdown.trigger('loaded.gl.dropdown'); + $selectbox.hide(); + data.issueURLSplit = issueURLSplit; + labelCount = 0; + if (data.labels.length) { + template = labelHTMLTemplate(data); + labelCount = data.labels.length; + } else { + template = labelNoneHTMLTemplate; + } + $value.removeAttr('style').html(template); + $sidebarCollapsedValue.text(labelCount); + $('.has-tooltip', $value).tooltip({ + container: 'body' + }); + return $value.find('a').each(function(i) { + return setTimeout((function(_this) { + return function() { + return gl.animate.animate($(_this), 'pulse'); + }; + })(this), 200 * i); + }); + }); + }; + return $dropdown.glDropdown({ + data: function(term, callback) { + return $.ajax({ + url: labelUrl + }).done(function(data) { + data = _.chain(data).groupBy(function(label) { + return label.title; + }).map(function(label) { + var color; + color = _.map(label, function(dup) { + return dup.color; + }); + return { + id: label[0].id, + title: label[0].title, + color: color, + duplicate: color.length > 1 + }; + }).value(); + if ($dropdown.hasClass('js-extra-options')) { + if (showNo) { + data.unshift({ + id: 0, + title: 'No Label' + }); + } + if (showAny) { + data.unshift({ + isAny: true, + title: 'Any Label' + }); + } + if (data.length > 2) { + data.splice(2, 0, 'divider'); + } + } + return callback(data); + }); + }, + renderRow: function(label, instance) { + var $a, $li, active, color, colorEl, indeterminate, removesAll, selectedClass, spacing; + $li = $('<li>'); + $a = $('<a href="#">'); + selectedClass = []; + removesAll = label.id === 0 || (label.id == null); + if ($dropdown.hasClass('js-filter-bulk-update')) { + indeterminate = instance.indeterminateIds; + active = instance.activeIds; + if (indeterminate.indexOf(label.id) !== -1) { + selectedClass.push('is-indeterminate'); + } + if (active.indexOf(label.id) !== -1) { + i = selectedClass.indexOf('is-indeterminate'); + if (i !== -1) { + selectedClass.splice(i, 1); + } + selectedClass.push('is-active'); + instance.addInput(this.fieldName, label.id); + } + } + if ($form.find("input[type='hidden'][name='" + ($dropdown.data('fieldName')) + "'][value='" + (this.id(label)) + "']").length) { + selectedClass.push('is-active'); + } + if ($dropdown.hasClass('js-multiselect') && removesAll) { + selectedClass.push('dropdown-clear-active'); + } + if (label.duplicate) { + spacing = 100 / label.color.length; + label.color = label.color.filter(function(color, i) { + return i < 4; + }); + color = _.map(label.color, function(color, i) { + var percentFirst, percentSecond; + percentFirst = Math.floor(spacing * i); + percentSecond = Math.floor(spacing * (i + 1)); + return color + " " + percentFirst + "%," + color + " " + percentSecond + "% "; + }).join(','); + color = "linear-gradient(" + color + ")"; + } else { + if (label.color != null) { + color = label.color[0]; + } + } + if (color) { + colorEl = "<span class='dropdown-label-box' style='background: " + color + "'></span>"; + } else { + colorEl = ''; + } + if (label.id) { + selectedClass.push('label-item'); + $a.attr('data-label-id', label.id); + } + $a.addClass(selectedClass.join(' ')).html(colorEl + " " + label.title); + return $li.html($a).prop('outerHTML'); + }, + persistWhenHide: $dropdown.data('persistWhenHide'), + search: { + fields: ['title'] + }, + selectable: true, + filterable: true, + toggleLabel: function(selected, el) { + var selected_labels; + selected_labels = $('.js-label-select').siblings('.dropdown-menu-labels').find('.is-active'); + if (selected && (selected.title != null)) { + if (selected_labels.length > 1) { + return selected.title + " +" + (selected_labels.length - 1) + " more"; + } else { + return selected.title; + } + } else if (!selected && selected_labels.length !== 0) { + if (selected_labels.length > 1) { + return ($(selected_labels[0]).text()) + " +" + (selected_labels.length - 1) + " more"; + } else if (selected_labels.length === 1) { + return $(selected_labels).text(); + } + } else { + return defaultLabel; + } + }, + fieldName: $dropdown.data('field-name'), + id: function(label) { + if ($dropdown.hasClass("js-filter-submit") && (label.isAny == null)) { + return label.title; + } else { + return label.id; + } + }, + hidden: function() { + var isIssueIndex, isMRIndex, page, selectedLabels; + page = $('body').data('page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = page === 'projects:merge_requests:index'; + $selectbox.hide(); + $value.removeAttr('style'); + if ($dropdown.hasClass('js-multiselect')) { + if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']"); + Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + $dropdown.closest('form').submit(); + } else { + if (!$dropdown.hasClass('js-filter-bulk-update')) { + saveLabelData(); + } + } + } + if ($dropdown.hasClass('js-filter-bulk-update')) { + if (!this.options.persistWhenHide) { + return $dropdown.parent().find('.is-active, .is-indeterminate').removeClass(); + } + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + clicked: function(label) { + var isIssueIndex, isMRIndex, page; + _this.enableBulkLabelDropdown(); + if ($dropdown.hasClass('js-filter-bulk-update')) { + return; + } + page = $('body').data('page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = page === 'projects:merge_requests:index'; + if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if (!$dropdown.hasClass('js-multiselect')) { + selectedLabel = label.title; + return Issuable.filterResults($dropdown.closest('form')); + } + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else { + if ($dropdown.hasClass('js-multiselect')) { + + } else { + return saveLabelData(); + } + } + }, + setIndeterminateIds: function() { + if (this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + return this.indeterminateIds = _this.getIndeterminateIds(); + } + }, + setActiveIds: function() { + if (this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + return this.activeIds = _this.getActiveIds(); + } + } + }); + }); + this.bindEvents(); + } + + LabelsSelect.prototype.bindEvents = function() { + return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue); + }; + + LabelsSelect.prototype.onSelectCheckboxIssue = function() { + if ($('.selected_issue:checked').length) { + return; + } + $('.issues_bulk_update .labels-filter input[type="hidden"]').remove(); + return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label'); + }; + + LabelsSelect.prototype.getIndeterminateIds = function() { + var label_ids; + label_ids = []; + $('.selected_issue:checked').each(function(i, el) { + var issue_id; + issue_id = $(el).data('id'); + return label_ids.push($("#issue_" + issue_id).data('labels')); + }); + return _.flatten(label_ids); + }; + + LabelsSelect.prototype.getActiveIds = function() { + var label_ids; + label_ids = []; + $('.selected_issue:checked').each(function(i, el) { + var issue_id; + issue_id = $(el).data('id'); + return label_ids.push($("#issue_" + issue_id).data('labels')); + }); + return _.intersection.apply(_, label_ids); + }; + + LabelsSelect.prototype.enableBulkLabelDropdown = function() { + var issuableBulkActions; + if ($('.selected_issue:checked').length) { + issuableBulkActions = $('.bulk-update').data('bulkActions'); + return issuableBulkActions.willUpdateLabels = true; + } + }; + + return LabelsSelect; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee deleted file mode 100644 index 7688609b301..00000000000 --- a/app/assets/javascripts/labels_select.js.coffee +++ /dev/null @@ -1,386 +0,0 @@ -class @LabelsSelect - constructor: -> - _this = @ - - $('.js-label-select').each (i, dropdown) -> - $dropdown = $(dropdown) - projectId = $dropdown.data('project-id') - labelUrl = $dropdown.data('labels') - issueUpdateURL = $dropdown.data('issueUpdate') - selectedLabel = $dropdown.data('selected') - if selectedLabel? and not $dropdown.hasClass 'js-multiselect' - selectedLabel = selectedLabel.split(',') - newLabelField = $('#new_label_name') - newColorField = $('#new_label_color') - showNo = $dropdown.data('show-no') - showAny = $dropdown.data('show-any') - defaultLabel = $dropdown.data('default-label') - abilityName = $dropdown.data('ability-name') - $selectbox = $dropdown.closest('.selectbox') - $block = $selectbox.closest('.block') - $form = $dropdown.closest('form') - $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span') - $value = $block.find('.value') - $newLabelError = $('.js-label-error') - $colorPreview = $('.js-dropdown-label-color-preview') - $newLabelCreateButton = $('.js-new-label-btn') - - $newLabelError.hide() - $loading = $block.find('.block-loading').fadeOut() - - issueURLSplit = issueUpdateURL.split('/') if issueUpdateURL? - if issueUpdateURL - labelHTMLTemplate = _.template( - '<% _.each(labels, function(label){ %> - <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> - <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> - <%- label.title %> - </span> - </a> - <% }); %>' - ) - labelNoneHTMLTemplate = '<span class="no-value">None</span>' - - if newLabelField.length - - # Suggested colors in the dropdown to chose from pre-chosen colors - $('.suggest-colors-dropdown a').on "click", (e) -> - e.preventDefault() - e.stopPropagation() - newColorField - .val($(this).data('color')) - .trigger('change') - $colorPreview - .css 'background-color', $(this).data('color') - .parent() - .addClass 'is-active' - - # Cancel button takes back to first page - resetForm = -> - newLabelField - .val '' - .trigger 'change' - newColorField - .val '' - .trigger 'change' - $colorPreview - .css 'background-color', '' - .parent() - .removeClass 'is-active' - - $('.dropdown-menu-back').on 'click', -> - resetForm() - - $('.js-cancel-label-btn').on 'click', (e) -> - e.preventDefault() - e.stopPropagation() - resetForm() - $('.dropdown-menu-back', $dropdown.parent()).trigger 'click' - - # Listen for change and keyup events on label and color field - # This allows us to enable the button when ready - enableLabelCreateButton = -> - if newLabelField.val() isnt '' and newColorField.val() isnt '' - $newLabelError.hide() - $newLabelCreateButton.enable() - else - $newLabelCreateButton.disable() - - saveLabel = -> - # Create new label with API - Api.newLabel projectId, { - name: newLabelField.val() - color: newColorField.val() - }, (label) -> - $newLabelCreateButton.enable() - - if label.message? - errors = _.map label.message, (value, key) -> - "#{key} #{value[0]}" - - $newLabelError - .html errors.join("<br/>") - .show() - else - $('.dropdown-menu-back', $dropdown.parent()).trigger 'click' - - newLabelField.on 'keyup change', enableLabelCreateButton - - newColorField.on 'keyup change', enableLabelCreateButton - - # Send the API call to create the label - $newLabelCreateButton - .disable() - .on 'click', (e) -> - e.preventDefault() - e.stopPropagation() - saveLabel() - - saveLabelData = -> - selected = $dropdown - .closest('.selectbox') - .find("input[name='#{$dropdown.data('field-name')}']") - .map(-> - @value - ).get() - data = {} - data[abilityName] = {} - data[abilityName].label_ids = selected - if not selected.length - data[abilityName].label_ids = [''] - $loading.fadeIn() - $dropdown.trigger('loading.gl.dropdown') - $.ajax( - type: 'PUT' - url: issueUpdateURL - dataType: 'JSON' - data: data - ).done (data) -> - $loading.fadeOut() - $dropdown.trigger('loaded.gl.dropdown') - $selectbox.hide() - data.issueURLSplit = issueURLSplit - labelCount = 0 - if data.labels.length - template = labelHTMLTemplate(data) - labelCount = data.labels.length - else - template = labelNoneHTMLTemplate - $value - .removeAttr('style') - .html(template) - $sidebarCollapsedValue.text(labelCount) - - $('.has-tooltip', $value).tooltip(container: 'body') - - $value - .find('a') - .each((i) -> - setTimeout(=> - gl.animate.animate($(@), 'pulse') - ,200 * i - ) - ) - - - $dropdown.glDropdown( - data: (term, callback) -> - $.ajax( - url: labelUrl - ).done (data) -> - data = _.chain data - .groupBy (label) -> - label.title - .map (label) -> - color = _.map label, (dup) -> - dup.color - - return { - id: label[0].id - title: label[0].title - color: color - duplicate: color.length > 1 - } - .value() - - if $dropdown.hasClass 'js-extra-options' - if showNo - data.unshift( - id: 0 - title: 'No Label' - ) - - if showAny - data.unshift( - isAny: true - title: 'Any Label' - ) - - if data.length > 2 - data.splice 2, 0, 'divider' - - callback data - - renderRow: (label, instance) -> - $li = $('<li>') - $a = $('<a href="#">') - - selectedClass = [] - removesAll = label.id is 0 or not label.id? - - if $dropdown.hasClass('js-filter-bulk-update') - indeterminate = instance.indeterminateIds - active = instance.activeIds - - if indeterminate.indexOf(label.id) isnt -1 - selectedClass.push 'is-indeterminate' - - if active.indexOf(label.id) isnt -1 - # Remove is-indeterminate class if the item will be marked as active - i = selectedClass.indexOf 'is-indeterminate' - selectedClass.splice i, 1 unless i is -1 - - selectedClass.push 'is-active' - - # Add input manually - instance.addInput @fieldName, label.id - - if $form.find("input[type='hidden']\ - [name='#{$dropdown.data('fieldName')}']\ - [value='#{this.id(label)}']").length - selectedClass.push 'is-active' - - if $dropdown.hasClass('js-multiselect') and removesAll - selectedClass.push 'dropdown-clear-active' - - if label.duplicate - spacing = 100 / label.color.length - - # Reduce the colors to 4 - label.color = label.color.filter (color, i) -> - i < 4 - - color = _.map(label.color, (color, i) -> - percentFirst = Math.floor(spacing * i) - percentSecond = Math.floor(spacing * (i + 1)) - "#{color} #{percentFirst}%,#{color} #{percentSecond}% " - ).join(',') - color = "linear-gradient(#{color})" - else - if label.color? - color = label.color[0] - - if color - colorEl = "<span class='dropdown-label-box' style='background: #{color}'></span>" - else - colorEl = '' - - # We need to identify which items are actually labels - if label.id - selectedClass.push('label-item') - $a.attr('data-label-id', label.id) - - $a.addClass(selectedClass.join(' ')) - .html("#{colorEl} #{label.title}") - - # Return generated html - $li.html($a).prop('outerHTML') - persistWhenHide: $dropdown.data('persistWhenHide') - search: - fields: ['title'] - selectable: true - filterable: true - toggleLabel: (selected, el) -> - selected_labels = $('.js-label-select').siblings('.dropdown-menu-labels').find('.is-active') - - if selected and selected.title? - if selected_labels.length > 1 - "#{selected.title} +#{selected_labels.length - 1} more" - else - selected.title - else if not selected and selected_labels.length isnt 0 - if selected_labels.length > 1 - "#{$(selected_labels[0]).text()} +#{selected_labels.length - 1} more" - else if selected_labels.length is 1 - $(selected_labels).text() - else - defaultLabel - fieldName: $dropdown.data('field-name') - id: (label) -> - if $dropdown.hasClass("js-filter-submit") and not label.isAny? - label.title - else - label.id - - hidden: -> - page = $('body').data 'page' - isIssueIndex = page is 'projects:issues:index' - isMRIndex = page is 'projects:merge_requests:index' - - $selectbox.hide() - # display:block overrides the hide-collapse rule - $value.removeAttr('style') - if $dropdown.hasClass 'js-multiselect' - if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex) - selectedLabels = $dropdown - .closest('form') - .find("input:hidden[name='#{$dropdown.data('fieldName')}']") - Issuable.filterResults $dropdown.closest('form') - else if $dropdown.hasClass('js-filter-submit') - $dropdown.closest('form').submit() - else - if not $dropdown.hasClass 'js-filter-bulk-update' - saveLabelData() - - if $dropdown.hasClass('js-filter-bulk-update') - # If we are persisting state we need the classes - if not @options.persistWhenHide - $dropdown.parent().find('.is-active, .is-indeterminate').removeClass() - - multiSelect: $dropdown.hasClass 'js-multiselect' - clicked: (label) -> - _this.enableBulkLabelDropdown() - - if $dropdown.hasClass('js-filter-bulk-update') - return - - page = $('body').data 'page' - isIssueIndex = page is 'projects:issues:index' - isMRIndex = page is 'projects:merge_requests:index' - if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex) - if not $dropdown.hasClass 'js-multiselect' - selectedLabel = label.title - Issuable.filterResults $dropdown.closest('form') - else if $dropdown.hasClass 'js-filter-submit' - $dropdown.closest('form').submit() - else - if $dropdown.hasClass 'js-multiselect' - return - else - saveLabelData() - - setIndeterminateIds: -> - if @dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update') - @indeterminateIds = _this.getIndeterminateIds() - - setActiveIds: -> - if @dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update') - @activeIds = _this.getActiveIds() - ) - - @bindEvents() - - bindEvents: -> - $('body').on 'change', '.selected_issue', @onSelectCheckboxIssue - - onSelectCheckboxIssue: -> - return if $('.selected_issue:checked').length - - # Remove inputs - $('.issues_bulk_update .labels-filter input[type="hidden"]').remove() - - # Also restore button text - $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label') - - getIndeterminateIds: -> - label_ids = [] - - $('.selected_issue:checked').each (i, el) -> - issue_id = $(el).data('id') - label_ids.push $("#issue_#{issue_id}").data('labels') - - _.flatten(label_ids) - - getActiveIds: -> - label_ids = [] - - $('.selected_issue:checked').each (i, el) -> - issue_id = $(el).data('id') - label_ids.push $("#issue_#{issue_id}").data('labels') - - _.intersection.apply _, label_ids - - enableBulkLabelDropdown: -> - if $('.selected_issue:checked').length - issuableBulkActions = $('.bulk-update').data('bulkActions') - issuableBulkActions.willUpdateLabels = true diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js new file mode 100644 index 00000000000..ce472f3bcd0 --- /dev/null +++ b/app/assets/javascripts/layout_nav.js @@ -0,0 +1,27 @@ +(function() { + var hideEndFade; + + hideEndFade = function($scrollingTabs) { + return $scrollingTabs.each(function() { + var $this; + $this = $(this); + return $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth')); + }); + }; + + $(function() { + hideEndFade($('.scrolling-tabs')); + $(window).off('resize.nav').on('resize.nav', function() { + return hideEndFade($('.scrolling-tabs')); + }); + return $('.scrolling-tabs').on('scroll', function(event) { + var $this, currentPosition, maxPosition; + $this = $(this); + currentPosition = $this.scrollLeft(); + maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); + $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0); + return $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); + }); + }); + +}).call(this); diff --git a/app/assets/javascripts/layout_nav.js.coffee b/app/assets/javascripts/layout_nav.js.coffee deleted file mode 100644 index f639f7f5892..00000000000 --- a/app/assets/javascripts/layout_nav.js.coffee +++ /dev/null @@ -1,24 +0,0 @@ -hideEndFade = ($scrollingTabs) -> - $scrollingTabs.each -> - $this = $(@) - - $this - .siblings('.fade-right') - .toggleClass('scrolling', $this.width() < $this.prop('scrollWidth')) - -$ -> - - hideEndFade($('.scrolling-tabs')) - - $(window) - .off 'resize.nav' - .on 'resize.nav', -> - hideEndFade($('.scrolling-tabs')) - - $('.scrolling-tabs').on 'scroll', (event) -> - $this = $(this) - currentPosition = $this.scrollLeft() - maxPosition = $this.prop('scrollWidth') - $this.outerWidth() - - $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0) - $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1) diff --git a/app/assets/javascripts/lib/chart.js b/app/assets/javascripts/lib/chart.js new file mode 100644 index 00000000000..8d5e52286b7 --- /dev/null +++ b/app/assets/javascripts/lib/chart.js @@ -0,0 +1,7 @@ + +/*= require Chart */ + +(function() { + + +}).call(this); diff --git a/app/assets/javascripts/lib/chart.js.coffee b/app/assets/javascripts/lib/chart.js.coffee deleted file mode 100644 index 82217fc5107..00000000000 --- a/app/assets/javascripts/lib/chart.js.coffee +++ /dev/null @@ -1 +0,0 @@ -#= require Chart diff --git a/app/assets/javascripts/lib/cropper.js b/app/assets/javascripts/lib/cropper.js new file mode 100644 index 00000000000..8ee81804513 --- /dev/null +++ b/app/assets/javascripts/lib/cropper.js @@ -0,0 +1,7 @@ + +/*= require cropper */ + +(function() { + + +}).call(this); diff --git a/app/assets/javascripts/lib/cropper.js.coffee b/app/assets/javascripts/lib/cropper.js.coffee deleted file mode 100644 index 32536d23fe3..00000000000 --- a/app/assets/javascripts/lib/cropper.js.coffee +++ /dev/null @@ -1 +0,0 @@ -#= require cropper diff --git a/app/assets/javascripts/lib/d3.js b/app/assets/javascripts/lib/d3.js new file mode 100644 index 00000000000..31e6033e756 --- /dev/null +++ b/app/assets/javascripts/lib/d3.js @@ -0,0 +1,7 @@ + +/*= require d3 */ + +(function() { + + +}).call(this); diff --git a/app/assets/javascripts/lib/d3.js.coffee b/app/assets/javascripts/lib/d3.js.coffee deleted file mode 100644 index 74f0a0bb06a..00000000000 --- a/app/assets/javascripts/lib/d3.js.coffee +++ /dev/null @@ -1 +0,0 @@ -#= require d3 diff --git a/app/assets/javascripts/lib/raphael.js b/app/assets/javascripts/lib/raphael.js new file mode 100644 index 00000000000..923c575dcfe --- /dev/null +++ b/app/assets/javascripts/lib/raphael.js @@ -0,0 +1,13 @@ + +/*= require raphael */ + + +/*= require g.raphael */ + + +/*= require g.bar */ + +(function() { + + +}).call(this); diff --git a/app/assets/javascripts/lib/raphael.js.coffee b/app/assets/javascripts/lib/raphael.js.coffee deleted file mode 100644 index ab8e5979b87..00000000000 --- a/app/assets/javascripts/lib/raphael.js.coffee +++ /dev/null @@ -1,3 +0,0 @@ -#= require raphael -#= require g.raphael -#= require g.bar diff --git a/app/assets/javascripts/lib/utils/animate.js b/app/assets/javascripts/lib/utils/animate.js new file mode 100644 index 00000000000..d36efdabc93 --- /dev/null +++ b/app/assets/javascripts/lib/utils/animate.js @@ -0,0 +1,49 @@ +(function() { + (function(w) { + if (w.gl == null) { + w.gl = {}; + } + if (gl.animate == null) { + gl.animate = {}; + } + gl.animate.animate = function($el, animation, options, done) { + if ((options != null ? options.cssStart : void 0) != null) { + $el.css(options.cssStart); + } + $el.removeClass(animation + ' animated').addClass(animation + ' animated').one('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function() { + $(this).removeClass(animation + ' animated'); + if (done != null) { + done(); + } + if ((options != null ? options.cssEnd : void 0) != null) { + $el.css(options.cssEnd); + } + }); + }; + gl.animate.animateEach = function($els, animation, time, options, done) { + var dfd; + dfd = $.Deferred(); + if (!$els.length) { + dfd.resolve(); + } + $els.each(function(i) { + setTimeout((function(_this) { + return function() { + var $this; + $this = $(_this); + return gl.animate.animate($this, animation, options, function() { + if (i === $els.length - 1) { + dfd.resolve(); + if (done != null) { + return done(); + } + } + }); + }; + })(this), time * i); + }); + return dfd.promise(); + }; + })(window); + +}).call(this); diff --git a/app/assets/javascripts/lib/utils/animate.js.coffee b/app/assets/javascripts/lib/utils/animate.js.coffee deleted file mode 100644 index ec3b44d6126..00000000000 --- a/app/assets/javascripts/lib/utils/animate.js.coffee +++ /dev/null @@ -1,39 +0,0 @@ -((w) -> - if not w.gl? then w.gl = {} - if not gl.animate? then gl.animate = {} - - gl.animate.animate = ($el, animation, options, done) -> - if options?.cssStart? - $el.css(options.cssStart) - $el - .removeClass(animation + ' animated') - .addClass(animation + ' animated') - .one 'webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', -> - $(this).removeClass(animation + ' animated') - if done? - done() - if options?.cssEnd? - $el.css(options.cssEnd) - return - return - - gl.animate.animateEach = ($els, animation, time, options, done) -> - dfd = $.Deferred() - if not $els.length - dfd.resolve() - $els.each((i) -> - setTimeout(=> - $this = $(@) - gl.animate.animate($this, animation, options, => - if i is $els.length - 1 - dfd.resolve() - if done? - done() - ) - ,time * i - ) - return - ) - return dfd.promise() - return -) window
\ No newline at end of file diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js new file mode 100644 index 00000000000..9299d0eabd2 --- /dev/null +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -0,0 +1,60 @@ +(function() { + (function(w) { + var base; + w.gl || (w.gl = {}); + (base = w.gl).utils || (base.utils = {}); + w.gl.utils.isInGroupsPage = function() { + return gl.utils.getPagePath() === 'groups'; + }; + w.gl.utils.isInProjectPage = function() { + return gl.utils.getPagePath() === 'projects'; + }; + w.gl.utils.getProjectSlug = function() { + if (this.isInProjectPage()) { + return $('body').data('project'); + } else { + return null; + } + }; + w.gl.utils.getGroupSlug = function() { + if (this.isInGroupsPage()) { + return $('body').data('group'); + } else { + return null; + } + }; + gl.utils.updateTooltipTitle = function($tooltipEl, newTitle) { + return $tooltipEl.tooltip('destroy').attr('title', newTitle).tooltip('fixTitle'); + }; + gl.utils.preventDisabledButtons = function() { + return $('.btn').click(function(e) { + if ($(this).hasClass('disabled')) { + e.preventDefault(); + e.stopImmediatePropagation(); + return false; + } + }); + }; + gl.utils.getPagePath = function() { + return $('body').data('page').split(':')[0]; + }; + return jQuery.timefor = function(time, suffix, expiredLabel) { + var suffixFromNow, timefor; + if (!time) { + return ''; + } + suffix || (suffix = 'remaining'); + expiredLabel || (expiredLabel = 'Past due'); + jQuery.timeago.settings.allowFuture = true; + suffixFromNow = jQuery.timeago.settings.strings.suffixFromNow; + jQuery.timeago.settings.strings.suffixFromNow = suffix; + timefor = $.timeago(time); + if (timefor.indexOf('ago') > -1) { + timefor = expiredLabel; + } + jQuery.timeago.settings.strings.suffixFromNow = suffixFromNow; + return timefor; + }; + })(window); + +}).call(this); diff --git a/app/assets/javascripts/lib/utils/common_utils.js.coffee b/app/assets/javascripts/lib/utils/common_utils.js.coffee deleted file mode 100644 index d4dd3dc329a..00000000000 --- a/app/assets/javascripts/lib/utils/common_utils.js.coffee +++ /dev/null @@ -1,68 +0,0 @@ -((w) -> - - w.gl or= {} - w.gl.utils or= {} - - w.gl.utils.isInGroupsPage = -> - - return gl.utils.getPagePath() is 'groups' - - - w.gl.utils.isInProjectPage = -> - - return gl.utils.getPagePath() is 'projects' - - - w.gl.utils.getProjectSlug = -> - - return if @isInProjectPage() then $('body').data 'project' else null - - - w.gl.utils.getGroupSlug = -> - - return if @isInGroupsPage() then $('body').data 'group' else null - - - - gl.utils.updateTooltipTitle = ($tooltipEl, newTitle) -> - - $tooltipEl - .tooltip 'destroy' - .attr 'title', newTitle - .tooltip 'fixTitle' - - - gl.utils.preventDisabledButtons = -> - - $('.btn').click (e) -> - if $(this).hasClass 'disabled' - e.preventDefault() - e.stopImmediatePropagation() - return false - - gl.utils.getPagePath = -> - return $('body').data('page').split(':')[0] - - - jQuery.timefor = (time, suffix, expiredLabel) -> - - return '' unless time - - suffix or= 'remaining' - expiredLabel or= 'Past due' - - jQuery.timeago.settings.allowFuture = yes - - { suffixFromNow } = jQuery.timeago.settings.strings - jQuery.timeago.settings.strings.suffixFromNow = suffix - - timefor = $.timeago time - - if timefor.indexOf('ago') > -1 - timefor = expiredLabel - - jQuery.timeago.settings.strings.suffixFromNow = suffixFromNow - - return timefor - -) window diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js new file mode 100644 index 00000000000..10afa7e4329 --- /dev/null +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -0,0 +1,72 @@ +(function() { + (function(w) { + var base; + if (w.gl == null) { + w.gl = {}; + } + if ((base = w.gl).utils == null) { + base.utils = {}; + } + w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + w.gl.utils.formatDate = function(datetime) { + return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); + }; + + w.gl.utils.getDayName = function(date) { + return this.days[date.getDay()]; + }; + + w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) { + if (setTimeago == null) { + setTimeago = true; + } + $timeagoEls.each(function() { + var $el; + $el = $(this); + return $el.attr('title', gl.utils.formatDate($el.attr('datetime'))); + }); + if (setTimeago) { + $timeagoEls.timeago(); + $timeagoEls.tooltip('destroy'); + return $timeagoEls.tooltip({ + template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>' + }); + } + }; + + w.gl.utils.shortTimeAgo = function($el) { + var shortLocale, tmpLocale; + shortLocale = { + prefixAgo: null, + prefixFromNow: null, + suffixAgo: 'ago', + suffixFromNow: 'from now', + seconds: '1 min', + minute: '1 min', + minutes: '%d mins', + hour: '1 hr', + hours: '%d hrs', + day: '1 day', + days: '%d days', + month: '1 month', + months: '%d months', + year: '1 year', + years: '%d years', + wordSeparator: ' ', + numbers: [] + }; + tmpLocale = $.timeago.settings.strings; + $el.each(function(el) { + var $el1; + $el1 = $(this); + return $el1.attr('title', gl.utils.formatDate($el.attr('datetime'))); + }); + $.timeago.settings.strings = shortLocale; + $el.timeago(); + $.timeago.settings.strings = tmpLocale; + }; + + })(window); + +}).call(this); diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js.coffee b/app/assets/javascripts/lib/utils/datetime_utility.js.coffee deleted file mode 100644 index 2371e913844..00000000000 --- a/app/assets/javascripts/lib/utils/datetime_utility.js.coffee +++ /dev/null @@ -1,28 +0,0 @@ -((w) -> - - w.gl ?= {} - w.gl.utils ?= {} - w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] - - w.gl.utils.formatDate = (datetime) -> - dateFormat(datetime, 'mmm d, yyyy h:MMtt Z') - - w.gl.utils.getDayName = (date) -> - this.days[date.getDay()] - - w.gl.utils.localTimeAgo = ($timeagoEls, setTimeago = true) -> - $timeagoEls.each( -> - $el = $(@) - $el.attr('title', gl.utils.formatDate($el.attr('datetime'))) - ) - - if setTimeago - $timeagoEls.timeago() - $timeagoEls.tooltip('destroy') - - # Recreate with custom template - $timeagoEls.tooltip( - template: '<div class="tooltip local-timeago" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>' - ) - -) window diff --git a/app/assets/javascripts/lib/utils/md5.js b/app/assets/javascripts/lib/utils/md5.js deleted file mode 100644 index b63716eaad2..00000000000 --- a/app/assets/javascripts/lib/utils/md5.js +++ /dev/null @@ -1,211 +0,0 @@ -function md5 (str) { - // http://kevin.vanzonneveld.net - // + original by: Webtoolkit.info (http://www.webtoolkit.info/) - // + namespaced by: Michael White (http://getsprink.com) - // + tweaked by: Jack - // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) - // + input by: Brett Zamir (http://brett-zamir.me) - // + bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) - // - depends on: utf8_encode - // * example 1: md5('Kevin van Zonneveld'); - // * returns 1: '6e658d4bfcb59cc13f96c14450ac40b9' - var xl; - - var rotateLeft = function (lValue, iShiftBits) { - return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits)); - }; - - var addUnsigned = function (lX, lY) { - var lX4, lY4, lX8, lY8, lResult; - lX8 = (lX & 0x80000000); - lY8 = (lY & 0x80000000); - lX4 = (lX & 0x40000000); - lY4 = (lY & 0x40000000); - lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF); - if (lX4 & lY4) { - return (lResult ^ 0x80000000 ^ lX8 ^ lY8); - } - if (lX4 | lY4) { - if (lResult & 0x40000000) { - return (lResult ^ 0xC0000000 ^ lX8 ^ lY8); - } else { - return (lResult ^ 0x40000000 ^ lX8 ^ lY8); - } - } else { - return (lResult ^ lX8 ^ lY8); - } - }; - - var _F = function (x, y, z) { - return (x & y) | ((~x) & z); - }; - var _G = function (x, y, z) { - return (x & z) | (y & (~z)); - }; - var _H = function (x, y, z) { - return (x ^ y ^ z); - }; - var _I = function (x, y, z) { - return (y ^ (x | (~z))); - }; - - var _FF = function (a, b, c, d, x, s, ac) { - a = addUnsigned(a, addUnsigned(addUnsigned(_F(b, c, d), x), ac)); - return addUnsigned(rotateLeft(a, s), b); - }; - - var _GG = function (a, b, c, d, x, s, ac) { - a = addUnsigned(a, addUnsigned(addUnsigned(_G(b, c, d), x), ac)); - return addUnsigned(rotateLeft(a, s), b); - }; - - var _HH = function (a, b, c, d, x, s, ac) { - a = addUnsigned(a, addUnsigned(addUnsigned(_H(b, c, d), x), ac)); - return addUnsigned(rotateLeft(a, s), b); - }; - - var _II = function (a, b, c, d, x, s, ac) { - a = addUnsigned(a, addUnsigned(addUnsigned(_I(b, c, d), x), ac)); - return addUnsigned(rotateLeft(a, s), b); - }; - - var convertToWordArray = function (str) { - var lWordCount; - var lMessageLength = str.length; - var lNumberOfWords_temp1 = lMessageLength + 8; - var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64; - var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16; - var lWordArray = new Array(lNumberOfWords - 1); - var lBytePosition = 0; - var lByteCount = 0; - while (lByteCount < lMessageLength) { - lWordCount = (lByteCount - (lByteCount % 4)) / 4; - lBytePosition = (lByteCount % 4) * 8; - lWordArray[lWordCount] = (lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition)); - lByteCount++; - } - lWordCount = (lByteCount - (lByteCount % 4)) / 4; - lBytePosition = (lByteCount % 4) * 8; - lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition); - lWordArray[lNumberOfWords - 2] = lMessageLength << 3; - lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29; - return lWordArray; - }; - - var wordToHex = function (lValue) { - var wordToHexValue = "", - wordToHexValue_temp = "", - lByte, lCount; - for (lCount = 0; lCount <= 3; lCount++) { - lByte = (lValue >>> (lCount * 8)) & 255; - wordToHexValue_temp = "0" + lByte.toString(16); - wordToHexValue = wordToHexValue + wordToHexValue_temp.substr(wordToHexValue_temp.length - 2, 2); - } - return wordToHexValue; - }; - - var x = [], - k, AA, BB, CC, DD, a, b, c, d, S11 = 7, - S12 = 12, - S13 = 17, - S14 = 22, - S21 = 5, - S22 = 9, - S23 = 14, - S24 = 20, - S31 = 4, - S32 = 11, - S33 = 16, - S34 = 23, - S41 = 6, - S42 = 10, - S43 = 15, - S44 = 21; - - str = this.utf8_encode(str); - x = convertToWordArray(str); - a = 0x67452301; - b = 0xEFCDAB89; - c = 0x98BADCFE; - d = 0x10325476; - - xl = x.length; - for (k = 0; k < xl; k += 16) { - AA = a; - BB = b; - CC = c; - DD = d; - a = _FF(a, b, c, d, x[k + 0], S11, 0xD76AA478); - d = _FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756); - c = _FF(c, d, a, b, x[k + 2], S13, 0x242070DB); - b = _FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE); - a = _FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF); - d = _FF(d, a, b, c, x[k + 5], S12, 0x4787C62A); - c = _FF(c, d, a, b, x[k + 6], S13, 0xA8304613); - b = _FF(b, c, d, a, x[k + 7], S14, 0xFD469501); - a = _FF(a, b, c, d, x[k + 8], S11, 0x698098D8); - d = _FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF); - c = _FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1); - b = _FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE); - a = _FF(a, b, c, d, x[k + 12], S11, 0x6B901122); - d = _FF(d, a, b, c, x[k + 13], S12, 0xFD987193); - c = _FF(c, d, a, b, x[k + 14], S13, 0xA679438E); - b = _FF(b, c, d, a, x[k + 15], S14, 0x49B40821); - a = _GG(a, b, c, d, x[k + 1], S21, 0xF61E2562); - d = _GG(d, a, b, c, x[k + 6], S22, 0xC040B340); - c = _GG(c, d, a, b, x[k + 11], S23, 0x265E5A51); - b = _GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA); - a = _GG(a, b, c, d, x[k + 5], S21, 0xD62F105D); - d = _GG(d, a, b, c, x[k + 10], S22, 0x2441453); - c = _GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681); - b = _GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8); - a = _GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6); - d = _GG(d, a, b, c, x[k + 14], S22, 0xC33707D6); - c = _GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87); - b = _GG(b, c, d, a, x[k + 8], S24, 0x455A14ED); - a = _GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905); - d = _GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8); - c = _GG(c, d, a, b, x[k + 7], S23, 0x676F02D9); - b = _GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A); - a = _HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942); - d = _HH(d, a, b, c, x[k + 8], S32, 0x8771F681); - c = _HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122); - b = _HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C); - a = _HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44); - d = _HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9); - c = _HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60); - b = _HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70); - a = _HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6); - d = _HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA); - c = _HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085); - b = _HH(b, c, d, a, x[k + 6], S34, 0x4881D05); - a = _HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039); - d = _HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5); - c = _HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8); - b = _HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665); - a = _II(a, b, c, d, x[k + 0], S41, 0xF4292244); - d = _II(d, a, b, c, x[k + 7], S42, 0x432AFF97); - c = _II(c, d, a, b, x[k + 14], S43, 0xAB9423A7); - b = _II(b, c, d, a, x[k + 5], S44, 0xFC93A039); - a = _II(a, b, c, d, x[k + 12], S41, 0x655B59C3); - d = _II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92); - c = _II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D); - b = _II(b, c, d, a, x[k + 1], S44, 0x85845DD1); - a = _II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F); - d = _II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0); - c = _II(c, d, a, b, x[k + 6], S43, 0xA3014314); - b = _II(b, c, d, a, x[k + 13], S44, 0x4E0811A1); - a = _II(a, b, c, d, x[k + 4], S41, 0xF7537E82); - d = _II(d, a, b, c, x[k + 11], S42, 0xBD3AF235); - c = _II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB); - b = _II(b, c, d, a, x[k + 9], S44, 0xEB86D391); - a = addUnsigned(a, AA); - b = addUnsigned(b, BB); - c = addUnsigned(c, CC); - d = addUnsigned(d, DD); - } - - var temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d); - - return temp.toLowerCase(); -} diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js new file mode 100644 index 00000000000..42b6ac0589e --- /dev/null +++ b/app/assets/javascripts/lib/utils/notify.js @@ -0,0 +1,41 @@ +(function() { + (function(w) { + var notificationGranted, notifyMe, notifyPermissions; + notificationGranted = function(message, opts, onclick) { + var notification; + notification = new Notification(message, opts); + setTimeout(function() { + return notification.close(); + }, 8000); + if (onclick) { + return notification.onclick = onclick; + } + }; + notifyPermissions = function() { + if ('Notification' in window) { + return Notification.requestPermission(); + } + }; + notifyMe = function(message, body, icon, onclick) { + var opts; + opts = { + body: body, + icon: icon + }; + if (!('Notification' in window)) { + + } else if (Notification.permission === 'granted') { + return notificationGranted(message, opts, onclick); + } else if (Notification.permission !== 'denied') { + return Notification.requestPermission(function(permission) { + if (permission === 'granted') { + return notificationGranted(message, opts, onclick); + } + }); + } + }; + w.notify = notifyMe; + return w.notifyPermissions = notifyPermissions; + })(window); + +}).call(this); diff --git a/app/assets/javascripts/lib/utils/notify.js.coffee b/app/assets/javascripts/lib/utils/notify.js.coffee deleted file mode 100644 index 9e28353ac34..00000000000 --- a/app/assets/javascripts/lib/utils/notify.js.coffee +++ /dev/null @@ -1,35 +0,0 @@ -((w) -> - notificationGranted = (message, opts, onclick) -> - notification = new Notification(message, opts) - - # Hide the notification after X amount of seconds - setTimeout -> - notification.close() - , 8000 - - if onclick - notification.onclick = onclick - - notifyPermissions = -> - if 'Notification' of window - Notification.requestPermission() - - notifyMe = (message, body, icon, onclick) -> - opts = - body: body - icon: icon - # Let's check if the browser supports notifications - if !('Notification' of window) - # do nothing - else if Notification.permission == 'granted' - # If it's okay let's create a notification - notificationGranted message, opts, onclick - else if Notification.permission != 'denied' - Notification.requestPermission (permission) -> - # If the user accepts, let's create a notification - if permission == 'granted' - notificationGranted message, opts, onclick - - w.notify = notifyMe - w.notifyPermissions = notifyPermissions -) window diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js new file mode 100644 index 00000000000..130479642f3 --- /dev/null +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -0,0 +1,112 @@ +(function() { + (function(w) { + var base; + if (w.gl == null) { + w.gl = {}; + } + if ((base = w.gl).text == null) { + base.text = {}; + } + gl.text.randomString = function() { + return Math.random().toString(36).substring(7); + }; + gl.text.replaceRange = function(s, start, end, substitute) { + return s.substring(0, start) + substitute + s.substring(end); + }; + gl.text.selectedText = function(text, textarea) { + return text.substring(textarea.selectionStart, textarea.selectionEnd); + }; + gl.text.lineBefore = function(text, textarea) { + var split; + split = text.substring(0, textarea.selectionStart).trim().split('\n'); + return split[split.length - 1]; + }; + gl.text.lineAfter = function(text, textarea) { + return text.substring(textarea.selectionEnd).trim().split('\n')[0]; + }; + gl.text.blockTagText = function(text, textArea, blockTag, selected) { + var lineAfter, lineBefore; + lineBefore = this.lineBefore(text, textArea); + lineAfter = this.lineAfter(text, textArea); + if (lineBefore === blockTag && lineAfter === blockTag) { + if (blockTag != null) { + textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); + textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); + } + return selected; + } else { + return blockTag + "\n" + selected + "\n" + blockTag; + } + }; + gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) { + var insertText, inserted, selectedSplit, startChar; + selectedSplit = selected.split('\n'); + startChar = !wrap && textArea.selectionStart > 0 ? '\n' : ''; + if (selectedSplit.length > 1 && (!wrap || (blockTag != null))) { + if (blockTag != null) { + insertText = this.blockTagText(text, textArea, blockTag, selected); + } else { + insertText = selectedSplit.map(function(val) { + if (val.indexOf(tag) === 0) { + return "" + (val.replace(tag, '')); + } else { + return "" + tag + val; + } + }).join('\n'); + } + } else { + insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); + } + if (document.queryCommandSupported('insertText')) { + inserted = document.execCommand('insertText', false, insertText); + } + if (!inserted) { + try { + document.execCommand("ms-beginUndoUnit"); + } catch (undefined) {} + textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); + try { + document.execCommand("ms-endUndoUnit"); + } catch (undefined) {} + } + return this.moveCursor(textArea, tag, wrap); + }; + gl.text.moveCursor = function(textArea, tag, wrapped) { + var pos; + if (!textArea.setSelectionRange) { + return; + } + if (textArea.selectionStart === textArea.selectionEnd) { + if (wrapped) { + pos = textArea.selectionStart - tag.length; + } else { + pos = textArea.selectionStart; + } + return textArea.setSelectionRange(pos, pos); + } + }; + gl.text.updateText = function(textArea, tag, blockTag, wrap) { + var $textArea, oldVal, selected, text; + $textArea = $(textArea); + oldVal = $textArea.val(); + textArea = $textArea.get(0); + text = $textArea.val(); + selected = this.selectedText(text, textArea); + $textArea.focus(); + return this.insertText(textArea, text, tag, blockTag, selected, wrap); + }; + gl.text.init = function(form) { + var self; + self = this; + return $('.js-md', form).off('click').on('click', function() { + var $this; + $this = $(this); + return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); + }); + }; + return gl.text.removeListeners = function(form) { + return $('.js-md', form).off(); + }; + })(window); + +}).call(this); diff --git a/app/assets/javascripts/lib/utils/text_utility.js.coffee b/app/assets/javascripts/lib/utils/text_utility.js.coffee deleted file mode 100644 index 2e1407f8738..00000000000 --- a/app/assets/javascripts/lib/utils/text_utility.js.coffee +++ /dev/null @@ -1,105 +0,0 @@ -((w) -> - w.gl ?= {} - w.gl.text ?= {} - - gl.text.randomString = -> Math.random().toString(36).substring(7) - - gl.text.replaceRange = (s, start, end, substitute) -> - s.substring(0, start) + substitute + s.substring(end); - - gl.text.selectedText = (text, textarea) -> - text.substring(textarea.selectionStart, textarea.selectionEnd) - - gl.text.lineBefore = (text, textarea) -> - split = text.substring(0, textarea.selectionStart).trim().split('\n') - split[split.length - 1] - - gl.text.lineAfter = (text, textarea) -> - text.substring(textarea.selectionEnd).trim().split('\n')[0] - - gl.text.blockTagText = (text, textArea, blockTag, selected) -> - lineBefore = @lineBefore(text, textArea) - lineAfter = @lineAfter(text, textArea) - - if lineBefore is blockTag and lineAfter is blockTag - # To remove the block tag we have to select the line before & after - if blockTag? - textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1) - textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1) - - selected - else - "#{blockTag}\n#{selected}\n#{blockTag}" - - gl.text.insertText = (textArea, text, tag, blockTag, selected, wrap) -> - selectedSplit = selected.split('\n') - startChar = if not wrap and textArea.selectionStart > 0 then '\n' else '' - - if selectedSplit.length > 1 and (not wrap or blockTag?) - if blockTag? - insertText = @blockTagText(text, textArea, blockTag, selected) - else - insertText = selectedSplit.map((val) -> - if val.indexOf(tag) is 0 - "#{val.replace(tag, '')}" - else - "#{tag}#{val}" - ).join('\n') - else - insertText = "#{startChar}#{tag}#{selected}#{if wrap then tag else ' '}" - - if document.queryCommandSupported('insertText') - inserted = document.execCommand 'insertText', false, insertText - - unless inserted - try - document.execCommand("ms-beginUndoUnit") - - textArea.value = @replaceRange( - text, - textArea.selectionStart, - textArea.selectionEnd, - insertText) - try - document.execCommand("ms-endUndoUnit") - - @moveCursor(textArea, tag, wrap) - - gl.text.moveCursor = (textArea, tag, wrapped) -> - return unless textArea.setSelectionRange - - if textArea.selectionStart is textArea.selectionEnd - if wrapped - pos = textArea.selectionStart - tag.length - else - pos = textArea.selectionStart - - textArea.setSelectionRange pos, pos - - gl.text.updateText = (textArea, tag, blockTag, wrap) -> - $textArea = $(textArea) - oldVal = $textArea.val() - textArea = $textArea.get(0) - text = $textArea.val() - selected = @selectedText(text, textArea) - $textArea.focus() - - @insertText(textArea, text, tag, blockTag, selected, wrap) - - gl.text.init = (form) -> - self = @ - $('.js-md', form) - .off 'click' - .on 'click', -> - $this = $(@) - self.updateText( - $this.closest('.md-area').find('textarea'), - $this.data('md-tag'), - $this.data('md-block'), - not $this.data('md-prepend') - ) - - gl.text.removeListeners = (form) -> - $('.js-md', form).off() - -) window diff --git a/app/assets/javascripts/lib/utils/type_utility.js b/app/assets/javascripts/lib/utils/type_utility.js new file mode 100644 index 00000000000..dc30babd645 --- /dev/null +++ b/app/assets/javascripts/lib/utils/type_utility.js @@ -0,0 +1,15 @@ +(function() { + (function(w) { + var base; + if (w.gl == null) { + w.gl = {}; + } + if ((base = w.gl).utils == null) { + base.utils = {}; + } + return w.gl.utils.isObject = function(obj) { + return (obj != null) && (obj.constructor === Object); + }; + })(window); + +}).call(this); diff --git a/app/assets/javascripts/lib/utils/type_utility.js.coffee b/app/assets/javascripts/lib/utils/type_utility.js.coffee deleted file mode 100644 index 957f0d86b36..00000000000 --- a/app/assets/javascripts/lib/utils/type_utility.js.coffee +++ /dev/null @@ -1,9 +0,0 @@ -((w) -> - - w.gl ?= {} - w.gl.utils ?= {} - - w.gl.utils.isObject = (obj) -> - obj? and (obj.constructor is Object) - -) window diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js new file mode 100644 index 00000000000..fffbfd19745 --- /dev/null +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -0,0 +1,64 @@ +(function() { + (function(w) { + var base; + if (w.gl == null) { + w.gl = {}; + } + if ((base = w.gl).utils == null) { + base.utils = {}; + } + w.gl.utils.getParameterValues = function(sParam) { + var i, sPageURL, sParameterName, sURLVariables, values; + sPageURL = decodeURIComponent(window.location.search.substring(1)); + sURLVariables = sPageURL.split('&'); + sParameterName = void 0; + values = []; + i = 0; + while (i < sURLVariables.length) { + sParameterName = sURLVariables[i].split('='); + if (sParameterName[0] === sParam) { + values.push(sParameterName[1]); + } + i++; + } + return values; + }; + w.gl.utils.mergeUrlParams = function(params, url) { + var lastChar, newUrl, paramName, paramValue, pattern; + newUrl = decodeURIComponent(url); + for (paramName in params) { + paramValue = params[paramName]; + pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)"); + if (paramValue == null) { + newUrl = newUrl.replace(pattern, ''); + } else if (url.search(pattern) !== -1) { + newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2"); + } else { + newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue; + } + } + lastChar = newUrl[newUrl.length - 1]; + if (lastChar === '&') { + newUrl = newUrl.slice(0, -1); + } + return newUrl; + }; + return w.gl.utils.removeParamQueryString = function(url, param) { + var urlVariables, variables; + url = decodeURIComponent(url); + urlVariables = url.split('&'); + return ((function() { + var j, len, results; + results = []; + for (j = 0, len = urlVariables.length; j < len; j++) { + variables = urlVariables[j]; + if (variables.indexOf(param) === -1) { + results.push(variables); + } + } + return results; + })()).join('&'); + }; + })(window); + +}).call(this); diff --git a/app/assets/javascripts/lib/utils/url_utility.js.coffee b/app/assets/javascripts/lib/utils/url_utility.js.coffee deleted file mode 100644 index e8085e1c2e4..00000000000 --- a/app/assets/javascripts/lib/utils/url_utility.js.coffee +++ /dev/null @@ -1,52 +0,0 @@ -((w) -> - - w.gl ?= {} - w.gl.utils ?= {} - - # Returns an array containing the value(s) of the - # of the key passed as an argument - w.gl.utils.getParameterValues = (sParam) -> - sPageURL = decodeURIComponent(window.location.search.substring(1)) - sURLVariables = sPageURL.split('&') - sParameterName = undefined - values = [] - i = 0 - while i < sURLVariables.length - sParameterName = sURLVariables[i].split('=') - if sParameterName[0] is sParam - values.push(sParameterName[1]) - i++ - values - - # # - # @param {Object} params - url keys and value to merge - # @param {String} url - # # - w.gl.utils.mergeUrlParams = (params, url) -> - newUrl = decodeURIComponent(url) - for paramName, paramValue of params - pattern = new RegExp "\\b(#{paramName}=).*?(&|$)" - if not paramValue? - newUrl = newUrl.replace pattern, '' - else if url.search(pattern) isnt -1 - newUrl = newUrl.replace pattern, "$1#{paramValue}$2" - else - newUrl = "#{newUrl}#{(if newUrl.indexOf('?') > 0 then '&' else '?')}#{paramName}=#{paramValue}" - - # Remove a trailing ampersand - lastChar = newUrl[newUrl.length - 1] - - if lastChar is '&' - newUrl = newUrl.slice 0, -1 - - newUrl - - # removes parameter query string from url. returns the modified url - w.gl.utils.removeParamQueryString = (url, param) -> - url = decodeURIComponent(url) - urlVariables = url.split('&') - ( - variables for variables in urlVariables when variables.indexOf(param) is -1 - ).join('&') - -) window diff --git a/app/assets/javascripts/lib/utils/utf8_encode.js b/app/assets/javascripts/lib/utils/utf8_encode.js deleted file mode 100644 index 39ffe44dae0..00000000000 --- a/app/assets/javascripts/lib/utils/utf8_encode.js +++ /dev/null @@ -1,70 +0,0 @@ -function utf8_encode (argString) {
- // http://kevin.vanzonneveld.net
- // + original by: Webtoolkit.info (http://www.webtoolkit.info/)
- // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
- // + improved by: sowberry
- // + tweaked by: Jack
- // + bugfixed by: Onno Marsman
- // + improved by: Yves Sucaet
- // + bugfixed by: Onno Marsman
- // + bugfixed by: Ulrich
- // + bugfixed by: Rafal Kukawski
- // + improved by: kirilloid
- // + bugfixed by: kirilloid
- // * example 1: utf8_encode('Kevin van Zonneveld');
- // * returns 1: 'Kevin van Zonneveld'
-
- if (argString === null || typeof argString === "undefined") {
- return "";
- }
-
- var string = (argString + ''); // .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
- var utftext = '',
- start, end, stringl = 0;
-
- start = end = 0;
- stringl = string.length;
- for (var n = 0; n < stringl; n++) {
- var c1 = string.charCodeAt(n);
- var enc = null;
-
- if (c1 < 128) {
- end++;
- } else if (c1 > 127 && c1 < 2048) {
- enc = String.fromCharCode(
- (c1 >> 6) | 192,
- ( c1 & 63) | 128
- );
- } else if (c1 & 0xF800 != 0xD800) {
- enc = String.fromCharCode(
- (c1 >> 12) | 224,
- ((c1 >> 6) & 63) | 128,
- ( c1 & 63) | 128
- );
- } else { // surrogate pairs
- if (c1 & 0xFC00 != 0xD800) { throw new RangeError("Unmatched trail surrogate at " + n); }
- var c2 = string.charCodeAt(++n);
- if (c2 & 0xFC00 != 0xDC00) { throw new RangeError("Unmatched lead surrogate at " + (n-1)); }
- c1 = ((c1 & 0x3FF) << 10) + (c2 & 0x3FF) + 0x10000;
- enc = String.fromCharCode(
- (c1 >> 18) | 240,
- ((c1 >> 12) & 63) | 128,
- ((c1 >> 6) & 63) | 128,
- ( c1 & 63) | 128
- );
- }
- if (enc !== null) {
- if (end > start) {
- utftext += string.slice(start, end);
- }
- utftext += enc;
- start = end = n + 1;
- }
- }
-
- if (end > start) {
- utftext += string.slice(start, stringl);
- }
-
- return utftext;
-}
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js new file mode 100644 index 00000000000..f145bd3ad74 --- /dev/null +++ b/app/assets/javascripts/line_highlighter.js @@ -0,0 +1,115 @@ + +/*= require jquery.scrollTo */ + +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.LineHighlighter = (function() { + LineHighlighter.prototype.highlightClass = 'hll'; + + LineHighlighter.prototype._hash = ''; + + function LineHighlighter(hash) { + var range; + if (hash == null) { + hash = location.hash; + } + this.setHash = bind(this.setHash, this); + this.highlightLine = bind(this.highlightLine, this); + this.clickHandler = bind(this.clickHandler, this); + this._hash = hash; + this.bindEvents(); + if (hash !== '') { + range = this.hashToRange(hash); + if (range[0]) { + this.highlightRange(range); + $.scrollTo("#L" + range[0], { + offset: -150 + }); + } + } + } + + LineHighlighter.prototype.bindEvents = function() { + $('#blob-content-holder').on('mousedown', 'a[data-line-number]', this.clickHandler); + return $('#blob-content-holder').on('click', 'a[data-line-number]', function(event) { + return event.preventDefault(); + }); + }; + + LineHighlighter.prototype.clickHandler = function(event) { + var current, lineNumber, range; + event.preventDefault(); + this.clearHighlight(); + lineNumber = $(event.target).closest('a').data('line-number'); + current = this.hashToRange(this._hash); + if (!(current[0] && event.shiftKey)) { + this.setHash(lineNumber); + return this.highlightLine(lineNumber); + } else if (event.shiftKey) { + if (lineNumber < current[0]) { + range = [lineNumber, current[0]]; + } else { + range = [current[0], lineNumber]; + } + this.setHash(range[0], range[1]); + return this.highlightRange(range); + } + }; + + LineHighlighter.prototype.clearHighlight = function() { + return $("." + this.highlightClass).removeClass(this.highlightClass); + }; + + LineHighlighter.prototype.hashToRange = function(hash) { + var first, last, matches; + matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); + if (matches && matches.length) { + first = parseInt(matches[1]); + last = matches[2] ? parseInt(matches[2]) : null; + return [first, last]; + } else { + return [null, null]; + } + }; + + LineHighlighter.prototype.highlightLine = function(lineNumber) { + return $("#LC" + lineNumber).addClass(this.highlightClass); + }; + + LineHighlighter.prototype.highlightRange = function(range) { + var i, lineNumber, ref, ref1, results; + if (range[1]) { + results = []; + for (lineNumber = i = ref = range[0], ref1 = range[1]; ref <= ref1 ? i <= ref1 : i >= ref1; lineNumber = ref <= ref1 ? ++i : --i) { + results.push(this.highlightLine(lineNumber)); + } + return results; + } else { + return this.highlightLine(range[0]); + } + }; + + LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) { + var hash; + if (lastLineNumber) { + hash = "#L" + firstLineNumber + "-" + lastLineNumber; + } else { + hash = "#L" + firstLineNumber; + } + this._hash = hash; + return this.__setLocationHash__(hash); + }; + + LineHighlighter.prototype.__setLocationHash__ = function(value) { + return history.pushState({ + turbolinks: false, + url: value + }, document.title, value); + }; + + return LineHighlighter; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/line_highlighter.js.coffee b/app/assets/javascripts/line_highlighter.js.coffee deleted file mode 100644 index 2254a3f91ae..00000000000 --- a/app/assets/javascripts/line_highlighter.js.coffee +++ /dev/null @@ -1,148 +0,0 @@ -# LineHighlighter -# -# Handles single- and multi-line selection and highlight for blob views. -# -#= require jquery.scrollTo -# -# ### Example Markup -# -# <div id="blob-content-holder"> -# <div class="file-content"> -# <div class="line-numbers"> -# <a href="#L1" id="L1" data-line-number="1">1</a> -# <a href="#L2" id="L2" data-line-number="2">2</a> -# <a href="#L3" id="L3" data-line-number="3">3</a> -# <a href="#L4" id="L4" data-line-number="4">4</a> -# <a href="#L5" id="L5" data-line-number="5">5</a> -# </div> -# <pre class="code highlight"> -# <code> -# <span id="LC1" class="line">...</span> -# <span id="LC2" class="line">...</span> -# <span id="LC3" class="line">...</span> -# <span id="LC4" class="line">...</span> -# <span id="LC5" class="line">...</span> -# </code> -# </pre> -# </div> -# </div> -# -class @LineHighlighter - # CSS class applied to highlighted lines - highlightClass: 'hll' - - # Internal copy of location.hash so we're not dependent on `location` in tests - _hash: '' - - # Initialize a LineHighlighter object - # - # hash - String URL hash for dependency injection in tests - constructor: (hash = location.hash) -> - @_hash = hash - - @bindEvents() - - unless hash == '' - range = @hashToRange(hash) - - if range[0] - @highlightRange(range) - - # Scroll to the first highlighted line on initial load - # Offset -50 for the sticky top bar, and another -100 for some context - $.scrollTo("#L#{range[0]}", offset: -150) - - bindEvents: -> - $('#blob-content-holder').on 'mousedown', 'a[data-line-number]', @clickHandler - - # While it may seem odd to bind to the mousedown event and then throw away - # the click event, there is a method to our madness. - # - # If not done this way, the line number anchor will sometimes keep its - # active state even when the event is cancelled, resulting in an ugly border - # around the link and/or a persisted underline text decoration. - - $('#blob-content-holder').on 'click', 'a[data-line-number]', (event) -> - event.preventDefault() - - clickHandler: (event) => - event.preventDefault() - - @clearHighlight() - - lineNumber = $(event.target).closest('a').data('line-number') - current = @hashToRange(@_hash) - - unless current[0] && event.shiftKey - # If there's no current selection, or there is but Shift wasn't held, - # treat this like a single-line selection. - @setHash(lineNumber) - @highlightLine(lineNumber) - else if event.shiftKey - if lineNumber < current[0] - range = [lineNumber, current[0]] - else - range = [current[0], lineNumber] - - @setHash(range[0], range[1]) - @highlightRange(range) - - # Unhighlight previously highlighted lines - clearHighlight: -> - $(".#{@highlightClass}").removeClass(@highlightClass) - - # Convert a URL hash String into line numbers - # - # hash - Hash String - # - # Examples: - # - # hashToRange('#L5') # => [5, null] - # hashToRange('#L5-15') # => [5, 15] - # hashToRange('#foo') # => [null, null] - # - # Returns an Array - hashToRange: (hash) -> - matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/) - - if matches && matches.length - first = parseInt(matches[1]) - last = if matches[2] then parseInt(matches[2]) else null - - [first, last] - else - [null, null] - - # Highlight a single line - # - # lineNumber - Line number to highlight - highlightLine: (lineNumber) => - $("#LC#{lineNumber}").addClass(@highlightClass) - - # Highlight all lines within a range - # - # range - Array containing the starting and ending line numbers - highlightRange: (range) -> - if range[1] - for lineNumber in [range[0]..range[1]] - @highlightLine(lineNumber) - else - @highlightLine(range[0]) - - # Set the URL hash string - setHash: (firstLineNumber, lastLineNumber) => - if lastLineNumber - hash = "#L#{firstLineNumber}-#{lastLineNumber}" - else - hash = "#L#{firstLineNumber}" - - @_hash = hash - @__setLocationHash__(hash) - - # Make the actual hash change in the browser - # - # This method is stubbed in tests. - __setLocationHash__: (value) -> - # We're using pushState instead of assigning location.hash directly to - # prevent the page from scrolling on the hashchange event - history.pushState({turbolinks: false, url: value}, document.title, value) diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js new file mode 100644 index 00000000000..218f24fe908 --- /dev/null +++ b/app/assets/javascripts/logo.js @@ -0,0 +1,54 @@ +(function() { + var clearHighlights, currentTimer, defaultClass, delay, firstPiece, pieceIndex, pieces, start, stop, work; + + Turbolinks.enableProgressBar(); + + defaultClass = 'tanuki-shape'; + + pieces = ['path#tanuki-right-cheek', 'path#tanuki-right-eye, path#tanuki-right-ear', 'path#tanuki-nose', 'path#tanuki-left-eye, path#tanuki-left-ear', 'path#tanuki-left-cheek']; + + pieceIndex = 0; + + firstPiece = pieces[0]; + + currentTimer = null; + + delay = 150; + + clearHighlights = function() { + return $("." + defaultClass + ".highlight").attr('class', defaultClass); + }; + + start = function() { + clearHighlights(); + pieceIndex = 0; + if (pieces[0] !== firstPiece) { + pieces.reverse(); + } + if (currentTimer) { + clearInterval(currentTimer); + } + return currentTimer = setInterval(work, delay); + }; + + stop = function() { + clearInterval(currentTimer); + return clearHighlights(); + }; + + work = function() { + clearHighlights(); + $(pieces[pieceIndex]).attr('class', defaultClass + " highlight"); + if (pieceIndex === pieces.length - 1) { + pieceIndex = 0; + return pieces.reverse(); + } else { + return pieceIndex++; + } + }; + + $(document).on('page:fetch', start); + + $(document).on('page:change', stop); + +}).call(this); diff --git a/app/assets/javascripts/logo.js.coffee b/app/assets/javascripts/logo.js.coffee deleted file mode 100644 index dc2590a0355..00000000000 --- a/app/assets/javascripts/logo.js.coffee +++ /dev/null @@ -1,44 +0,0 @@ -Turbolinks.enableProgressBar(); - -defaultClass = 'tanuki-shape' -pieces = [ - 'path#tanuki-right-cheek', - 'path#tanuki-right-eye, path#tanuki-right-ear', - 'path#tanuki-nose', - 'path#tanuki-left-eye, path#tanuki-left-ear', - 'path#tanuki-left-cheek', -] -pieceIndex = 0 -firstPiece = pieces[0] - -currentTimer = null -delay = 150 - -clearHighlights = -> - $(".#{defaultClass}.highlight").attr('class', defaultClass) - -start = -> - clearHighlights() - pieceIndex = 0 - pieces.reverse() unless pieces[0] == firstPiece - clearInterval(currentTimer) if currentTimer - currentTimer = setInterval(work, delay) - -stop = -> - clearInterval(currentTimer) - clearHighlights() - -work = -> - clearHighlights() - $(pieces[pieceIndex]).attr('class', "#{defaultClass} highlight") - - # If we hit the last piece, reset the index and then reverse the array to - # get a nice back-and-forth sweeping look - if pieceIndex == pieces.length - 1 - pieceIndex = 0 - pieces.reverse() - else - pieceIndex++ - -$(document).on('page:fetch', start) -$(document).on('page:change', stop) diff --git a/app/assets/javascripts/markdown_preview.js b/app/assets/javascripts/markdown_preview.js new file mode 100644 index 00000000000..18fc7bae09a --- /dev/null +++ b/app/assets/javascripts/markdown_preview.js @@ -0,0 +1,150 @@ +(function() { + var lastTextareaPreviewed, markdownPreview, previewButtonSelector, writeButtonSelector; + + this.MarkdownPreview = (function() { + function MarkdownPreview() {} + + MarkdownPreview.prototype.referenceThreshold = 10; + + MarkdownPreview.prototype.ajaxCache = {}; + + MarkdownPreview.prototype.showPreview = function(form) { + var mdText, preview; + preview = form.find('.js-md-preview'); + mdText = form.find('textarea.markdown-area').val(); + if (mdText.trim().length === 0) { + preview.text('Nothing to preview.'); + return this.hideReferencedUsers(form); + } else { + preview.text('Loading...'); + return this.renderMarkdown(mdText, (function(_this) { + return function(response) { + preview.html(response.body); + preview.syntaxHighlight(); + return _this.renderReferencedUsers(response.references.users, form); + }; + })(this)); + } + }; + + MarkdownPreview.prototype.renderMarkdown = function(text, success) { + if (!window.markdown_preview_path) { + return; + } + if (text === this.ajaxCache.text) { + return success(this.ajaxCache.response); + } + return $.ajax({ + type: 'POST', + url: window.markdown_preview_path, + data: { + text: text + }, + dataType: 'json', + success: (function(_this) { + return function(response) { + _this.ajaxCache = { + text: text, + response: response + }; + return success(response); + }; + })(this) + }); + }; + + MarkdownPreview.prototype.hideReferencedUsers = function(form) { + var referencedUsers; + referencedUsers = form.find('.referenced-users'); + return referencedUsers.hide(); + }; + + MarkdownPreview.prototype.renderReferencedUsers = function(users, form) { + var referencedUsers; + referencedUsers = form.find('.referenced-users'); + if (referencedUsers.length) { + if (users.length >= this.referenceThreshold) { + referencedUsers.show(); + return referencedUsers.find('.js-referenced-users-count').text(users.length); + } else { + return referencedUsers.hide(); + } + } + }; + + return MarkdownPreview; + + })(); + + markdownPreview = new MarkdownPreview(); + + previewButtonSelector = '.js-md-preview-button'; + + writeButtonSelector = '.js-md-write-button'; + + lastTextareaPreviewed = null; + + $.fn.setupMarkdownPreview = function() { + var $form, form_textarea; + $form = $(this); + form_textarea = $form.find('textarea.markdown-area'); + form_textarea.on('input', function() { + return markdownPreview.hideReferencedUsers($form); + }); + return form_textarea.on('blur', function() { + return markdownPreview.showPreview($form); + }); + }; + + $(document).on('markdown-preview:show', function(e, $form) { + if (!$form) { + return; + } + lastTextareaPreviewed = $form.find('textarea.markdown-area'); + $form.find(writeButtonSelector).parent().removeClass('active'); + $form.find(previewButtonSelector).parent().addClass('active'); + $form.find('.md-write-holder').hide(); + $form.find('.md-preview-holder').show(); + return markdownPreview.showPreview($form); + }); + + $(document).on('markdown-preview:hide', function(e, $form) { + if (!$form) { + return; + } + lastTextareaPreviewed = null; + $form.find(writeButtonSelector).parent().addClass('active'); + $form.find(previewButtonSelector).parent().removeClass('active'); + $form.find('.md-write-holder').show(); + $form.find('textarea.markdown-area').focus(); + return $form.find('.md-preview-holder').hide(); + }); + + $(document).on('markdown-preview:toggle', function(e, keyboardEvent) { + var $target; + $target = $(keyboardEvent.target); + if ($target.is('textarea.markdown-area')) { + $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]); + return keyboardEvent.preventDefault(); + } else if (lastTextareaPreviewed) { + $target = lastTextareaPreviewed; + $(document).triggerHandler('markdown-preview:hide', [$target.closest('form')]); + return keyboardEvent.preventDefault(); + } + }); + + $(document).on('click', previewButtonSelector, function(e) { + var $form; + e.preventDefault(); + $form = $(this).closest('form'); + return $(document).triggerHandler('markdown-preview:show', [$form]); + }); + + $(document).on('click', writeButtonSelector, function(e) { + var $form; + e.preventDefault(); + $form = $(this).closest('form'); + return $(document).triggerHandler('markdown-preview:hide', [$form]); + }); + +}).call(this); diff --git a/app/assets/javascripts/markdown_preview.js.coffee b/app/assets/javascripts/markdown_preview.js.coffee deleted file mode 100644 index 2a0b9479445..00000000000 --- a/app/assets/javascripts/markdown_preview.js.coffee +++ /dev/null @@ -1,119 +0,0 @@ -# MarkdownPreview -# -# Handles toggling the "Write" and "Preview" tab clicks, rendering the preview, -# and showing a warning when more than `x` users are referenced. -# -class @MarkdownPreview - # Minimum number of users referenced before triggering a warning - referenceThreshold: 10 - ajaxCache: {} - - showPreview: (form) -> - preview = form.find('.js-md-preview') - mdText = form.find('textarea.markdown-area').val() - - if mdText.trim().length == 0 - preview.text('Nothing to preview.') - @hideReferencedUsers(form) - else - preview.text('Loading...') - @renderMarkdown mdText, (response) => - preview.html(response.body) - preview.syntaxHighlight() - @renderReferencedUsers(response.references.users, form) - - renderMarkdown: (text, success) -> - return unless window.markdown_preview_path - - return success(@ajaxCache.response) if text == @ajaxCache.text - - $.ajax - type: 'POST' - url: window.markdown_preview_path - data: { text: text } - dataType: 'json' - success: (response) => - @ajaxCache = text: text, response: response - success(response) - - hideReferencedUsers: (form) -> - referencedUsers = form.find('.referenced-users') - referencedUsers.hide() - - renderReferencedUsers: (users, form) -> - referencedUsers = form.find('.referenced-users') - - if referencedUsers.length - if users.length >= @referenceThreshold - referencedUsers.show() - referencedUsers.find('.js-referenced-users-count').text(users.length) - else - referencedUsers.hide() - -markdownPreview = new MarkdownPreview() - -previewButtonSelector = '.js-md-preview-button' -writeButtonSelector = '.js-md-write-button' -lastTextareaPreviewed = null - -$.fn.setupMarkdownPreview = -> - $form = $(this) - - form_textarea = $form.find('textarea.markdown-area') - - form_textarea.on 'input', -> markdownPreview.hideReferencedUsers($form) - form_textarea.on 'blur', -> markdownPreview.showPreview($form) - -$(document).on 'markdown-preview:show', (e, $form) -> - return unless $form - - lastTextareaPreviewed = $form.find('textarea.markdown-area') - - # toggle tabs - $form.find(writeButtonSelector).parent().removeClass('active') - $form.find(previewButtonSelector).parent().addClass('active') - - # toggle content - $form.find('.md-write-holder').hide() - $form.find('.md-preview-holder').show() - - markdownPreview.showPreview($form) - -$(document).on 'markdown-preview:hide', (e, $form) -> - return unless $form - - lastTextareaPreviewed = null - - # toggle tabs - $form.find(writeButtonSelector).parent().addClass('active') - $form.find(previewButtonSelector).parent().removeClass('active') - - # toggle content - $form.find('.md-write-holder').show() - $form.find('textarea.markdown-area').focus() - $form.find('.md-preview-holder').hide() - -$(document).on 'markdown-preview:toggle', (e, keyboardEvent) -> - $target = $(keyboardEvent.target) - - if $target.is('textarea.markdown-area') - $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]) - keyboardEvent.preventDefault() - else if lastTextareaPreviewed - $target = lastTextareaPreviewed - $(document).triggerHandler('markdown-preview:hide', [$target.closest('form')]) - keyboardEvent.preventDefault() - -$(document).on 'click', previewButtonSelector, (e) -> - e.preventDefault() - - $form = $(this).closest('form') - - $(document).triggerHandler('markdown-preview:show', [$form]) - -$(document).on 'click', writeButtonSelector, (e) -> - e.preventDefault() - - $form = $(this).closest('form') - - $(document).triggerHandler('markdown-preview:hide', [$form]) diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js new file mode 100644 index 00000000000..47e6dd1084d --- /dev/null +++ b/app/assets/javascripts/merge_request.js @@ -0,0 +1,105 @@ + +/*= require jquery.waitforimages */ + + +/*= require task_list */ + + +/*= require merge_request_tabs */ + +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.MergeRequest = (function() { + function MergeRequest(opts) { + this.opts = opts != null ? opts : {}; + this.submitNoteForm = bind(this.submitNoteForm, this); + this.$el = $('.merge-request'); + this.$('.show-all-commits').on('click', (function(_this) { + return function() { + return _this.showAllCommits(); + }; + })(this)); + this.initTabs(); + this.disableTaskList(); + this.initMRBtnListeners(); + if ($("a.btn-close").length) { + this.initTaskList(); + } + } + + MergeRequest.prototype.$ = function(selector) { + return this.$el.find(selector); + }; + + MergeRequest.prototype.initTabs = function() { + if (this.opts.action !== 'new') { + return new MergeRequestTabs(this.opts); + } else { + return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show'); + } + }; + + MergeRequest.prototype.showAllCommits = function() { + this.$('.first-commits').remove(); + return this.$('.all-commits').removeClass('hide'); + }; + + MergeRequest.prototype.initTaskList = function() { + $('.detail-page-description .js-task-list-container').taskList('enable'); + return $(document).on('tasklist:changed', '.detail-page-description .js-task-list-container', this.updateTaskList); + }; + + MergeRequest.prototype.initMRBtnListeners = function() { + var _this; + _this = this; + return $('a.btn-close, a.btn-reopen').on('click', function(e) { + var $this, shouldSubmit; + $this = $(this); + shouldSubmit = $this.hasClass('btn-comment'); + if (shouldSubmit && $this.data('submitted')) { + return; + } + if (shouldSubmit) { + if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) { + e.preventDefault(); + e.stopImmediatePropagation(); + return _this.submitNoteForm($this.closest('form'), $this); + } + } + }); + }; + + MergeRequest.prototype.submitNoteForm = function(form, $button) { + var noteText; + noteText = form.find("textarea.js-note-text").val(); + if (noteText.trim().length > 0) { + form.submit(); + $button.data('submitted', true); + return $button.trigger('click'); + } + }; + + MergeRequest.prototype.disableTaskList = function() { + $('.detail-page-description .js-task-list-container').taskList('disable'); + return $(document).off('tasklist:changed', '.detail-page-description .js-task-list-container'); + }; + + MergeRequest.prototype.updateTaskList = function() { + var patchData; + patchData = {}; + patchData['merge_request'] = { + 'description': $('.js-task-list-field', this).val() + }; + return $.ajax({ + type: 'PATCH', + url: $('form.js-issuable-update').attr('action'), + data: patchData + }); + }; + + return MergeRequest; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/merge_request.js.coffee b/app/assets/javascripts/merge_request.js.coffee deleted file mode 100644 index dabfd91cf14..00000000000 --- a/app/assets/javascripts/merge_request.js.coffee +++ /dev/null @@ -1,82 +0,0 @@ -#= require jquery.waitforimages -#= require task_list - -#= require merge_request_tabs - -class @MergeRequest - # Initialize MergeRequest behavior - # - # Options: - # action - String, current controller action - # - constructor: (@opts = {}) -> - this.$el = $('.merge-request') - - this.$('.show-all-commits').on 'click', => - this.showAllCommits() - - @initTabs() - - # Prevent duplicate event bindings - @disableTaskList() - @initMRBtnListeners() - - if $("a.btn-close").length - @initTaskList() - - # Local jQuery finder - $: (selector) -> - this.$el.find(selector) - - initTabs: -> - if @opts.action != 'new' - # `MergeRequests#new` has no tab-persisting or lazy-loading behavior - new MergeRequestTabs(@opts) - else - # Show the first tab (Commits) - $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show') - - showAllCommits: -> - this.$('.first-commits').remove() - this.$('.all-commits').removeClass 'hide' - - initTaskList: -> - $('.detail-page-description .js-task-list-container').taskList('enable') - $(document).on 'tasklist:changed', '.detail-page-description .js-task-list-container', @updateTaskList - - initMRBtnListeners: -> - _this = @ - $('a.btn-close, a.btn-reopen').on 'click', (e) -> - $this = $(this) - shouldSubmit = $this.hasClass('btn-comment') - if shouldSubmit && $this.data('submitted') - return - if shouldSubmit - if $this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen') - e.preventDefault() - e.stopImmediatePropagation() - _this.submitNoteForm($this.closest('form'),$this) - - - submitNoteForm: (form, $button) => - noteText = form.find("textarea.js-note-text").val() - if noteText.trim().length > 0 - form.submit() - $button.data('submitted',true) - $button.trigger('click') - - - disableTaskList: -> - $('.detail-page-description .js-task-list-container').taskList('disable') - $(document).off 'tasklist:changed', '.detail-page-description .js-task-list-container' - - # TODO (rspeicher): Make the merge request description inline-editable like a - # note so that we can re-use its form here - updateTaskList: -> - patchData = {} - patchData['merge_request'] = {'description': $('.js-task-list-field', this).val()} - - $.ajax - type: 'PATCH' - url: $('form.js-issuable-update').attr('action') - data: patchData diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js new file mode 100644 index 00000000000..52c2ed61012 --- /dev/null +++ b/app/assets/javascripts/merge_request_tabs.js @@ -0,0 +1,239 @@ + +/*= require jquery.cookie */ + +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.MergeRequestTabs = (function() { + MergeRequestTabs.prototype.diffsLoaded = false; + + MergeRequestTabs.prototype.buildsLoaded = false; + + MergeRequestTabs.prototype.commitsLoaded = false; + + function MergeRequestTabs(opts) { + this.opts = opts != null ? opts : {}; + this.setCurrentAction = bind(this.setCurrentAction, this); + this.tabShown = bind(this.tabShown, this); + this.showTab = bind(this.showTab, this); + this._location = location; + this.bindEvents(); + this.activateTab(this.opts.action); + } + + MergeRequestTabs.prototype.bindEvents = function() { + $(document).on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown); + return $(document).on('click', '.js-show-tab', this.showTab); + }; + + MergeRequestTabs.prototype.showTab = function(event) { + event.preventDefault(); + return this.activateTab($(event.target).data('action')); + }; + + MergeRequestTabs.prototype.tabShown = function(event) { + var $target, action, navBarHeight; + $target = $(event.target); + action = $target.data('action'); + if (action === 'commits') { + this.loadCommits($target.attr('href')); + this.expandView(); + } else if (action === 'diffs') { + this.loadDiff($target.attr('href')); + if ((typeof bp !== "undefined" && bp !== null) && bp.getBreakpointSize() !== 'lg') { + this.shrinkView(); + } + navBarHeight = $('.navbar-gitlab').outerHeight(); + $.scrollTo(".merge-request-details .merge-request-tabs", { + offset: -navBarHeight + }); + } else if (action === 'builds') { + this.loadBuilds($target.attr('href')); + this.expandView(); + } else { + this.expandView(); + } + return this.setCurrentAction(action); + }; + + MergeRequestTabs.prototype.scrollToElement = function(container) { + var $el, navBarHeight; + if (window.location.hash) { + navBarHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight(); + $el = $(container + " " + window.location.hash + ":not(.match)"); + if ($el.length) { + return $.scrollTo(container + " " + window.location.hash + ":not(.match)", { + offset: -navBarHeight + }); + } + } + }; + + MergeRequestTabs.prototype.activateTab = function(action) { + if (action === 'show') { + action = 'notes'; + } + return $(".merge-request-tabs a[data-action='" + action + "']").tab('show'); + }; + + MergeRequestTabs.prototype.setCurrentAction = function(action) { + var new_state; + if (action === 'show') { + action = 'notes'; + } + new_state = this._location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, ''); + if (action !== 'notes') { + new_state += "/" + action; + } + new_state += this._location.search + this._location.hash; + history.replaceState({ + turbolinks: true, + url: new_state + }, document.title, new_state); + return new_state; + }; + + MergeRequestTabs.prototype.loadCommits = function(source) { + if (this.commitsLoaded) { + return; + } + return this._get({ + url: source + ".json", + success: (function(_this) { + return function(data) { + document.querySelector("div#commits").innerHTML = data.html; + gl.utils.localTimeAgo($('.js-timeago', 'div#commits')); + _this.commitsLoaded = true; + return _this.scrollToElement("#commits"); + }; + })(this) + }); + }; + + MergeRequestTabs.prototype.loadDiff = function(source) { + if (this.diffsLoaded) { + return; + } + return this._get({ + url: (source + ".json") + this._location.search, + success: (function(_this) { + return function(data) { + $('#diffs').html(data.html); + gl.utils.localTimeAgo($('.js-timeago', 'div#diffs')); + $('#diffs .js-syntax-highlight').syntaxHighlight(); + $('#diffs .diff-file').singleFileDiff(); + if (_this.diffViewType() === 'parallel') { + _this.expandViewContainer(); + } + _this.diffsLoaded = true; + _this.scrollToElement("#diffs"); + _this.highlighSelectedLine(); + _this.filesCommentButton = $('.files .diff-file').filesCommentButton(); + return $(document).off('click', '.diff-line-num a').on('click', '.diff-line-num a', function(e) { + e.preventDefault(); + window.location.hash = $(e.currentTarget).attr('href'); + _this.highlighSelectedLine(); + return _this.scrollToElement("#diffs"); + }); + }; + })(this) + }); + }; + + MergeRequestTabs.prototype.highlighSelectedLine = function() { + var $diffLine, diffLineTop, hashClassString, locationHash, navBarHeight; + $('.hll').removeClass('hll'); + locationHash = window.location.hash; + if (locationHash !== '') { + hashClassString = "." + (locationHash.replace('#', '')); + $diffLine = $(locationHash + ":not(.match)", $('#diffs')); + if (!$diffLine.is('tr')) { + $diffLine = $('#diffs').find("td" + locationHash + ", td" + hashClassString); + } else { + $diffLine = $diffLine.find('td'); + } + if ($diffLine.length) { + $diffLine.addClass('hll'); + diffLineTop = $diffLine.offset().top; + return navBarHeight = $('.navbar-gitlab').outerHeight(); + } + } + }; + + MergeRequestTabs.prototype.loadBuilds = function(source) { + if (this.buildsLoaded) { + return; + } + return this._get({ + url: source + ".json", + success: (function(_this) { + return function(data) { + document.querySelector("div#builds").innerHTML = data.html; + gl.utils.localTimeAgo($('.js-timeago', 'div#builds')); + _this.buildsLoaded = true; + return _this.scrollToElement("#builds"); + }; + })(this) + }); + }; + + MergeRequestTabs.prototype.toggleLoading = function(status) { + return $('.mr-loading-status .loading').toggle(status); + }; + + MergeRequestTabs.prototype._get = function(options) { + var defaults; + defaults = { + beforeSend: (function(_this) { + return function() { + return _this.toggleLoading(true); + }; + })(this), + complete: (function(_this) { + return function() { + return _this.toggleLoading(false); + }; + })(this), + dataType: 'json', + type: 'GET' + }; + options = $.extend({}, defaults, options); + return $.ajax(options); + }; + + MergeRequestTabs.prototype.diffViewType = function() { + return $('.inline-parallel-buttons a.active').data('view-type'); + }; + + MergeRequestTabs.prototype.expandViewContainer = function() { + return $('.container-fluid').removeClass('container-limited'); + }; + + MergeRequestTabs.prototype.shrinkView = function() { + var $gutterIcon; + $gutterIcon = $('.js-sidebar-toggle i:visible'); + return setTimeout(function() { + if ($gutterIcon.is('.fa-angle-double-right')) { + return $gutterIcon.closest('a').trigger('click', [true]); + } + }, 0); + }; + + MergeRequestTabs.prototype.expandView = function() { + var $gutterIcon; + if ($.cookie('collapsed_gutter') === 'true') { + return; + } + $gutterIcon = $('.js-sidebar-toggle i:visible'); + return setTimeout(function() { + if ($gutterIcon.is('.fa-angle-double-left')) { + return $gutterIcon.closest('a').trigger('click', [true]); + } + }, 0); + }; + + return MergeRequestTabs; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/merge_request_tabs.js.coffee b/app/assets/javascripts/merge_request_tabs.js.coffee deleted file mode 100644 index 86539e0d725..00000000000 --- a/app/assets/javascripts/merge_request_tabs.js.coffee +++ /dev/null @@ -1,252 +0,0 @@ -# MergeRequestTabs -# -# Handles persisting and restoring the current tab selection and lazily-loading -# content on the MergeRequests#show page. -# -#= require jquery.cookie -# -# ### Example Markup -# -# <ul class="nav-links merge-request-tabs"> -# <li class="notes-tab active"> -# <a data-action="notes" data-target="#notes" data-toggle="tab" href="/foo/bar/merge_requests/1"> -# Discussion -# </a> -# </li> -# <li class="commits-tab"> -# <a data-action="commits" data-target="#commits" data-toggle="tab" href="/foo/bar/merge_requests/1/commits"> -# Commits -# </a> -# </li> -# <li class="diffs-tab"> -# <a data-action="diffs" data-target="#diffs" data-toggle="tab" href="/foo/bar/merge_requests/1/diffs"> -# Diffs -# </a> -# </li> -# </ul> -# -# <div class="tab-content"> -# <div class="notes tab-pane active" id="notes"> -# Notes Content -# </div> -# <div class="commits tab-pane" id="commits"> -# Commits Content -# </div> -# <div class="diffs tab-pane" id="diffs"> -# Diffs Content -# </div> -# </div> -# -# <div class="mr-loading-status"> -# <div class="loading"> -# Loading Animation -# </div> -# </div> -# -class @MergeRequestTabs - diffsLoaded: false - buildsLoaded: false - commitsLoaded: false - - constructor: (@opts = {}) -> - # Store the `location` object, allowing for easier stubbing in tests - @_location = location - - @bindEvents() - @activateTab(@opts.action) - - bindEvents: -> - $(document).on 'shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', @tabShown - $(document).on 'click', '.js-show-tab', @showTab - - showTab: (event) => - event.preventDefault() - - @activateTab $(event.target).data('action') - - tabShown: (event) => - $target = $(event.target) - action = $target.data('action') - - if action == 'commits' - @loadCommits($target.attr('href')) - @expandView() - else if action == 'diffs' - @loadDiff($target.attr('href')) - if bp? and bp.getBreakpointSize() isnt 'lg' - @shrinkView() - - navBarHeight = $('.navbar-gitlab').outerHeight() - $.scrollTo(".merge-request-details .merge-request-tabs", offset: -navBarHeight) - else if action == 'builds' - @loadBuilds($target.attr('href')) - @expandView() - else - @expandView() - - @setCurrentAction(action) - - scrollToElement: (container) -> - if window.location.hash - navBarHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight() - - $el = $("#{container} #{window.location.hash}:not(.match)") - $.scrollTo("#{container} #{window.location.hash}:not(.match)", offset: -navBarHeight) if $el.length - - # Activate a tab based on the current action - activateTab: (action) -> - action = 'notes' if action == 'show' - $(".merge-request-tabs a[data-action='#{action}']").tab('show') - - # Replaces the current Merge Request-specific action in the URL with a new one - # - # If the action is "notes", the URL is reset to the standard - # `MergeRequests#show` route. - # - # Examples: - # - # location.pathname # => "/namespace/project/merge_requests/1" - # setCurrentAction('diffs') - # location.pathname # => "/namespace/project/merge_requests/1/diffs" - # - # location.pathname # => "/namespace/project/merge_requests/1/diffs" - # setCurrentAction('notes') - # location.pathname # => "/namespace/project/merge_requests/1" - # - # location.pathname # => "/namespace/project/merge_requests/1/diffs" - # setCurrentAction('commits') - # location.pathname # => "/namespace/project/merge_requests/1/commits" - # - # Returns the new URL String - setCurrentAction: (action) => - # Normalize action, just to be safe - action = 'notes' if action == 'show' - - # Remove a trailing '/commits' or '/diffs' - new_state = @_location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, '') - - # Append the new action if we're on a tab other than 'notes' - unless action == 'notes' - new_state += "/#{action}" - - # Ensure parameters and hash come along for the ride - new_state += @_location.search + @_location.hash - - # Replace the current history state with the new one without breaking - # Turbolinks' history. - # - # See https://github.com/rails/turbolinks/issues/363 - history.replaceState {turbolinks: true, url: new_state}, document.title, new_state - - new_state - - loadCommits: (source) -> - return if @commitsLoaded - - @_get - url: "#{source}.json" - success: (data) => - document.querySelector("div#commits").innerHTML = data.html - gl.utils.localTimeAgo($('.js-timeago', 'div#commits')) - @commitsLoaded = true - @scrollToElement("#commits") - - loadDiff: (source) -> - return if @diffsLoaded - @_get - url: "#{source}.json" + @_location.search - success: (data) => - $('#diffs').html data.html - gl.utils.localTimeAgo($('.js-timeago', 'div#diffs')) - $('#diffs .js-syntax-highlight').syntaxHighlight() - $('#diffs .diff-file').singleFileDiff() - @expandViewContainer() if @diffViewType() is 'parallel' - @diffsLoaded = true - @scrollToElement("#diffs") - @highlighSelectedLine() - @filesCommentButton = $('.files .diff-file').filesCommentButton() - - $(document) - .off 'click', '.diff-line-num a' - .on 'click', '.diff-line-num a', (e) => - e.preventDefault() - window.location.hash = $(e.currentTarget).attr 'href' - @highlighSelectedLine() - @scrollToElement("#diffs") - - highlighSelectedLine: -> - $('.hll').removeClass 'hll' - locationHash = window.location.hash - - if locationHash isnt '' - hashClassString = ".#{locationHash.replace('#', '')}" - $diffLine = $("#{locationHash}:not(.match)", $('#diffs')) - - if not $diffLine.is 'tr' - $diffLine = $('#diffs').find("td#{locationHash}, td#{hashClassString}") - else - $diffLine = $diffLine.find('td') - - if $diffLine.length - $diffLine.addClass 'hll' - diffLineTop = $diffLine.offset().top - navBarHeight = $('.navbar-gitlab').outerHeight() - - loadBuilds: (source) -> - return if @buildsLoaded - - @_get - url: "#{source}.json" - success: (data) => - document.querySelector("div#builds").innerHTML = data.html - gl.utils.localTimeAgo($('.js-timeago', 'div#builds')) - @buildsLoaded = true - @scrollToElement("#builds") - - # Show or hide the loading spinner - # - # status - Boolean, true to show, false to hide - toggleLoading: (status) -> - $('.mr-loading-status .loading').toggle(status) - - _get: (options) -> - defaults = { - beforeSend: => @toggleLoading(true) - complete: => @toggleLoading(false) - dataType: 'json' - type: 'GET' - } - - options = $.extend({}, defaults, options) - - $.ajax(options) - - # Returns diff view type - diffViewType: -> - $('.inline-parallel-buttons a.active').data('view-type') - - expandViewContainer: -> - $('.container-fluid').removeClass('container-limited') - - shrinkView: -> - $gutterIcon = $('.js-sidebar-toggle i:visible') - - # Wait until listeners are set - setTimeout( -> - # Only when sidebar is expanded - if $gutterIcon.is('.fa-angle-double-right') - $gutterIcon.closest('a').trigger('click', [true]) - , 0) - - # Expand the issuable sidebar unless the user explicitly collapsed it - expandView: -> - return if $.cookie('collapsed_gutter') == 'true' - - $gutterIcon = $('.js-sidebar-toggle i:visible') - - # Wait until listeners are set - setTimeout( -> - # Only when sidebar is collapsed - if $gutterIcon.is('.fa-angle-double-left') - $gutterIcon.closest('a').trigger('click', [true]) - , 0) diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js new file mode 100644 index 00000000000..362aaa906d0 --- /dev/null +++ b/app/assets/javascripts/merge_request_widget.js @@ -0,0 +1,185 @@ +(function() { + var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; + + this.MergeRequestWidget = (function() { + function MergeRequestWidget(opts) { + this.opts = opts; + $('#modal_merge_info').modal({ + show: false + }); + this.firstCICheck = true; + this.readyForCICheck = false; + this.cancel = false; + clearInterval(this.fetchBuildStatusInterval); + this.clearEventListeners(); + this.addEventListeners(); + this.getCIStatus(false); + this.pollCIStatus(); + notifyPermissions(); + } + + MergeRequestWidget.prototype.clearEventListeners = function() { + return $(document).off('page:change.merge_request'); + }; + + MergeRequestWidget.prototype.cancelPolling = function() { + return this.cancel = true; + }; + + MergeRequestWidget.prototype.addEventListeners = function() { + var allowedPages; + allowedPages = ['show', 'commits', 'builds', 'changes']; + return $(document).on('page:change.merge_request', (function(_this) { + return function() { + var page; + page = $('body').data('page').split(':').last(); + if (allowedPages.indexOf(page) < 0) { + clearInterval(_this.fetchBuildStatusInterval); + _this.cancelPolling(); + return _this.clearEventListeners(); + } + }; + })(this)); + }; + + MergeRequestWidget.prototype.mergeInProgress = function(deleteSourceBranch) { + if (deleteSourceBranch == null) { + deleteSourceBranch = false; + } + return $.ajax({ + type: 'GET', + url: $('.merge-request').data('url'), + success: (function(_this) { + return function(data) { + var callback, urlSuffix; + if (data.state === "merged") { + urlSuffix = deleteSourceBranch ? '?delete_source=true' : ''; + return window.location.href = window.location.pathname + urlSuffix; + } else if (data.merge_error) { + return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>"); + } else { + callback = function() { + return merge_request_widget.mergeInProgress(deleteSourceBranch); + }; + return setTimeout(callback, 2000); + } + }; + })(this), + dataType: 'json' + }); + }; + + MergeRequestWidget.prototype.getMergeStatus = function() { + return $.get(this.opts.merge_check_url, function(data) { + return $('.mr-state-widget').replaceWith(data); + }); + }; + + MergeRequestWidget.prototype.ciLabelForStatus = function(status) { + switch (status) { + case 'success': + return 'passed'; + case 'success_with_warnings': + return 'passed with warnings'; + default: + return status; + } + }; + + MergeRequestWidget.prototype.pollCIStatus = function() { + return this.fetchBuildStatusInterval = setInterval(((function(_this) { + return function() { + if (!_this.readyForCICheck) { + return; + } + _this.getCIStatus(true); + return _this.readyForCICheck = false; + }; + })(this)), 10000); + }; + + MergeRequestWidget.prototype.getCIStatus = function(showNotification) { + var _this; + _this = this; + $('.ci-widget-fetching').show(); + return $.getJSON(this.opts.ci_status_url, (function(_this) { + return function(data) { + var message, status, title; + if (_this.cancel) { + return; + } + _this.readyForCICheck = true; + if (data.status === '') { + return; + } + if (_this.firstCICheck || data.status !== _this.opts.ci_status && (data.status != null)) { + _this.opts.ci_status = data.status; + _this.showCIStatus(data.status); + if (data.coverage) { + _this.showCICoverage(data.coverage); + } + if (showNotification && !_this.firstCICheck) { + status = _this.ciLabelForStatus(data.status); + if (status === "preparing") { + title = _this.opts.ci_title.preparing; + status = status.charAt(0).toUpperCase() + status.slice(1); + message = _this.opts.ci_message.preparing.replace('{{status}}', status); + } else { + title = _this.opts.ci_title.normal; + message = _this.opts.ci_message.normal.replace('{{status}}', status); + } + title = title.replace('{{status}}', status); + message = message.replace('{{sha}}', data.sha); + message = message.replace('{{title}}', data.title); + notify(title, message, _this.opts.gitlab_icon, function() { + this.close(); + return Turbolinks.visit(_this.opts.builds_path); + }); + } + return _this.firstCICheck = false; + } + }; + })(this)); + }; + + MergeRequestWidget.prototype.showCIStatus = function(state) { + var allowed_states; + if (state == null) { + return; + } + $('.ci_widget').hide(); + allowed_states = ["failed", "canceled", "running", "pending", "success", "success_with_warnings", "skipped", "not_found"]; + if (indexOf.call(allowed_states, state) >= 0) { + $('.ci_widget.ci-' + state).show(); + switch (state) { + case "failed": + case "canceled": + case "not_found": + return this.setMergeButtonClass('btn-danger'); + case "running": + return this.setMergeButtonClass('btn-warning'); + case "success": + case "success_with_warnings": + return this.setMergeButtonClass('btn-create'); + } + } else { + $('.ci_widget.ci-error').show(); + return this.setMergeButtonClass('btn-danger'); + } + }; + + MergeRequestWidget.prototype.showCICoverage = function(coverage) { + var text; + text = 'Coverage ' + coverage + '%'; + return $('.ci_widget:visible .ci-coverage').text(text); + }; + + MergeRequestWidget.prototype.setMergeButtonClass = function(css_class) { + return $('.js-merge-button,.accept-action .dropdown-toggle').removeClass('btn-danger btn-warning btn-create').addClass(css_class); + }; + + return MergeRequestWidget; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/merge_request_widget.js.coffee b/app/assets/javascripts/merge_request_widget.js.coffee deleted file mode 100644 index 963a0550c35..00000000000 --- a/app/assets/javascripts/merge_request_widget.js.coffee +++ /dev/null @@ -1,143 +0,0 @@ -class @MergeRequestWidget - # Initialize MergeRequestWidget behavior - # - # check_enable - Boolean, whether to check automerge status - # merge_check_url - String, URL to use to check automerge status - # ci_status_url - String, URL to use to check CI status - # - - constructor: (@opts) -> - $('#modal_merge_info').modal(show: false) - @firstCICheck = true - @readyForCICheck = false - @cancel = false - clearInterval @fetchBuildStatusInterval - - @clearEventListeners() - @addEventListeners() - @getCIStatus(false) - @pollCIStatus() - notifyPermissions() - - clearEventListeners: -> - $(document).off 'page:change.merge_request' - - cancelPolling: -> - @cancel = true - - addEventListeners: -> - allowedPages = ['show', 'commits', 'builds', 'changes'] - $(document).on 'page:change.merge_request', => - page = $('body').data('page').split(':').last() - if allowedPages.indexOf(page) < 0 - clearInterval @fetchBuildStatusInterval - @cancelPolling() - @clearEventListeners() - - mergeInProgress: (deleteSourceBranch = false)-> - $.ajax - type: 'GET' - url: $('.merge-request').data('url') - success: (data) => - if data.state == "merged" - urlSuffix = if deleteSourceBranch then '?delete_source=true' else '' - - window.location.href = window.location.pathname + urlSuffix - else if data.merge_error - $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>") - else - callback = -> merge_request_widget.mergeInProgress(deleteSourceBranch) - setTimeout(callback, 2000) - dataType: 'json' - - getMergeStatus: -> - $.get @opts.merge_check_url, (data) -> - $('.mr-state-widget').replaceWith(data) - - ciLabelForStatus: (status) -> - switch status - when 'success' - 'passed' - when 'success_with_warnings' - 'passed with warnings' - else - status - - pollCIStatus: -> - @fetchBuildStatusInterval = setInterval ( => - return if not @readyForCICheck - - @getCIStatus(true) - - @readyForCICheck = false - ), 10000 - - getCIStatus: (showNotification) -> - _this = @ - $('.ci-widget-fetching').show() - - $.getJSON @opts.ci_status_url, (data) => - return if @cancel - @readyForCICheck = true - - if data.status is '' - return - - if @firstCICheck || data.status isnt @opts.ci_status and data.status? - @opts.ci_status = data.status - @showCIStatus data.status - if data.coverage - @showCICoverage data.coverage - - # The first check should only update the UI, a notification - # should only be displayed on status changes - if showNotification and not @firstCICheck - status = @ciLabelForStatus(data.status) - - if status is "preparing" - title = @opts.ci_title.preparing - status = status.charAt(0).toUpperCase() + status.slice(1); - message = @opts.ci_message.preparing.replace('{{status}}', status) - else - title = @opts.ci_title.normal - message = @opts.ci_message.normal.replace('{{status}}', status) - - title = title.replace('{{status}}', status) - message = message.replace('{{sha}}', data.sha) - message = message.replace('{{title}}', data.title) - - notify( - title, - message, - @opts.gitlab_icon, - -> - @close() - Turbolinks.visit _this.opts.builds_path - ) - @firstCICheck = false - - showCIStatus: (state) -> - return if not state? - $('.ci_widget').hide() - allowed_states = ["failed", "canceled", "running", "pending", "success", "success_with_warnings", "skipped", "not_found"] - if state in allowed_states - $('.ci_widget.ci-' + state).show() - switch state - when "failed", "canceled", "not_found" - @setMergeButtonClass('btn-danger') - when "running" - @setMergeButtonClass('btn-warning') - when "success", "success_with_warnings" - @setMergeButtonClass('btn-create') - else - $('.ci_widget.ci-error').show() - @setMergeButtonClass('btn-danger') - - showCICoverage: (coverage) -> - text = 'Coverage ' + coverage + '%' - $('.ci_widget:visible .ci-coverage').text(text) - - setMergeButtonClass: (css_class) -> - $('.js-merge-button,.accept-action .dropdown-toggle') - .removeClass('btn-danger btn-warning btn-create') - .addClass(css_class) diff --git a/app/assets/javascripts/merged_buttons.js b/app/assets/javascripts/merged_buttons.js new file mode 100644 index 00000000000..1fed38661a2 --- /dev/null +++ b/app/assets/javascripts/merged_buttons.js @@ -0,0 +1,45 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.MergedButtons = (function() { + function MergedButtons() { + this.removeSourceBranch = bind(this.removeSourceBranch, this); + this.$removeBranchWidget = $('.remove_source_branch_widget'); + this.$removeBranchProgress = $('.remove_source_branch_in_progress'); + this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); + this.cleanEventListeners(); + this.initEventListeners(); + } + + MergedButtons.prototype.cleanEventListeners = function() { + $(document).off('click', '.remove_source_branch'); + $(document).off('ajax:success', '.remove_source_branch'); + return $(document).off('ajax:error', '.remove_source_branch'); + }; + + MergedButtons.prototype.initEventListeners = function() { + $(document).on('click', '.remove_source_branch', this.removeSourceBranch); + $(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess); + return $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError); + }; + + MergedButtons.prototype.removeSourceBranch = function() { + this.$removeBranchWidget.hide(); + return this.$removeBranchProgress.show(); + }; + + MergedButtons.prototype.removeBranchSuccess = function() { + return location.reload(); + }; + + MergedButtons.prototype.removeBranchError = function() { + this.$removeBranchWidget.hide(); + this.$removeBranchProgress.hide(); + return this.$removeBranchFailed.show(); + }; + + return MergedButtons; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/merged_buttons.js.coffee b/app/assets/javascripts/merged_buttons.js.coffee deleted file mode 100644 index 4929295c10b..00000000000 --- a/app/assets/javascripts/merged_buttons.js.coffee +++ /dev/null @@ -1,30 +0,0 @@ -class @MergedButtons - constructor: -> - @$removeBranchWidget = $('.remove_source_branch_widget') - @$removeBranchProgress = $('.remove_source_branch_in_progress') - @$removeBranchFailed = $('.remove_source_branch_widget.failed') - - @cleanEventListeners() - @initEventListeners() - - cleanEventListeners: -> - $(document).off 'click', '.remove_source_branch' - $(document).off 'ajax:success', '.remove_source_branch' - $(document).off 'ajax:error', '.remove_source_branch' - - initEventListeners: -> - $(document).on 'click', '.remove_source_branch', @removeSourceBranch - $(document).on 'ajax:success', '.remove_source_branch', @removeBranchSuccess - $(document).on 'ajax:error', '.remove_source_branch', @removeBranchError - - removeSourceBranch: => - @$removeBranchWidget.hide() - @$removeBranchProgress.show() - - removeBranchSuccess: -> - location.reload() - - removeBranchError: -> - @$removeBranchWidget.hide() - @$removeBranchProgress.hide() - @$removeBranchFailed.show() diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js new file mode 100644 index 00000000000..e8d51da7d58 --- /dev/null +++ b/app/assets/javascripts/milestone.js @@ -0,0 +1,195 @@ +(function() { + this.Milestone = (function() { + Milestone.updateIssue = function(li, issue_url, data) { + return $.ajax({ + type: "PUT", + url: issue_url, + data: data, + success: (function(_this) { + return function(_data) { + return _this.successCallback(_data, li); + }; + })(this), + error: function(data) { + return new Flash("Issue update failed", 'alert'); + }, + dataType: "json" + }); + }; + + Milestone.sortIssues = function(data) { + var sort_issues_url; + sort_issues_url = location.href + "/sort_issues"; + return $.ajax({ + type: "PUT", + url: sort_issues_url, + data: data, + success: (function(_this) { + return function(_data) { + return _this.successCallback(_data); + }; + })(this), + error: function() { + return new Flash("Issues update failed", 'alert'); + }, + dataType: "json" + }); + }; + + Milestone.sortMergeRequests = function(data) { + var sort_mr_url; + sort_mr_url = location.href + "/sort_merge_requests"; + return $.ajax({ + type: "PUT", + url: sort_mr_url, + data: data, + success: (function(_this) { + return function(_data) { + return _this.successCallback(_data); + }; + })(this), + error: function(data) { + return new Flash("Issue update failed", 'alert'); + }, + dataType: "json" + }); + }; + + Milestone.updateMergeRequest = function(li, merge_request_url, data) { + return $.ajax({ + type: "PUT", + url: merge_request_url, + data: data, + success: (function(_this) { + return function(_data) { + return _this.successCallback(_data, li); + }; + })(this), + error: function(data) { + return new Flash("Issue update failed", 'alert'); + }, + dataType: "json" + }); + }; + + Milestone.successCallback = function(data, element) { + var img_tag; + if (data.assignee) { + img_tag = $('<img/>'); + img_tag.attr('src', data.assignee.avatar_url); + img_tag.addClass('avatar s16'); + $(element).find('.assignee-icon').html(img_tag); + } else { + $(element).find('.assignee-icon').html(''); + } + return $(element).effect('highlight'); + }; + + function Milestone() { + var oldMouseStart; + oldMouseStart = $.ui.sortable.prototype._mouseStart; + $.ui.sortable.prototype._mouseStart = function(event, overrideHandle, noActivation) { + this._trigger("beforeStart", event, this._uiHash()); + return oldMouseStart.apply(this, [event, overrideHandle, noActivation]); + }; + this.bindIssuesSorting(); + this.bindMergeRequestSorting(); + this.bindTabsSwitching(); + } + + Milestone.prototype.bindIssuesSorting = function() { + return $("#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed").sortable({ + connectWith: ".issues-sortable-list", + dropOnEmpty: true, + items: "li:not(.ui-sort-disabled)", + beforeStart: function(event, ui) { + return $(".issues-sortable-list").css("min-height", ui.item.outerHeight()); + }, + stop: function(event, ui) { + return $(".issues-sortable-list").css("min-height", "0px"); + }, + update: function(event, ui) { + var data; + if ($(this).find(ui.item).length > 0) { + data = $(this).sortable("serialize"); + return Milestone.sortIssues(data); + } + }, + receive: function(event, ui) { + var data, issue_id, issue_url, new_state; + new_state = $(this).data('state'); + issue_id = ui.item.data('iid'); + issue_url = ui.item.data('url'); + data = (function() { + switch (new_state) { + case 'ongoing': + return "issue[assignee_id]=" + gon.current_user_id; + case 'unassigned': + return "issue[assignee_id]="; + case 'closed': + return "issue[state_event]=close"; + } + })(); + if ($(ui.sender).data('state') === "closed") { + data += "&issue[state_event]=reopen"; + } + return Milestone.updateIssue(ui.item, issue_url, data); + } + }).disableSelection(); + }; + + 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(); + }); + }; + + Milestone.prototype.bindMergeRequestSorting = function() { + return $("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").sortable({ + connectWith: ".merge_requests-sortable-list", + dropOnEmpty: true, + items: "li:not(.ui-sort-disabled)", + beforeStart: function(event, ui) { + return $(".merge_requests-sortable-list").css("min-height", ui.item.outerHeight()); + }, + stop: function(event, ui) { + return $(".merge_requests-sortable-list").css("min-height", "0px"); + }, + update: function(event, ui) { + var data; + data = $(this).sortable("serialize"); + return Milestone.sortMergeRequests(data); + }, + receive: function(event, ui) { + var data, merge_request_id, merge_request_url, new_state; + new_state = $(this).data('state'); + merge_request_id = ui.item.data('iid'); + merge_request_url = ui.item.data('url'); + data = (function() { + switch (new_state) { + case 'ongoing': + return "merge_request[assignee_id]=" + gon.current_user_id; + case 'unassigned': + return "merge_request[assignee_id]="; + case 'closed': + return "merge_request[state_event]=close"; + } + })(); + if ($(ui.sender).data('state') === "closed") { + data += "&merge_request[state_event]=reopen"; + } + return Milestone.updateMergeRequest(ui.item, merge_request_url, data); + } + }).disableSelection(); + }; + + return Milestone; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/milestone.js.coffee b/app/assets/javascripts/milestone.js.coffee deleted file mode 100644 index a19e68b39e2..00000000000 --- a/app/assets/javascripts/milestone.js.coffee +++ /dev/null @@ -1,146 +0,0 @@ -class @Milestone - @updateIssue: (li, issue_url, data) -> - $.ajax - type: "PUT" - url: issue_url - data: data - success: (_data) => - @successCallback(_data, li) - error: (data) -> - new Flash("Issue update failed", 'alert') - dataType: "json" - - @sortIssues: (data) -> - sort_issues_url = location.href + "/sort_issues" - - $.ajax - type: "PUT" - url: sort_issues_url - data: data - success: (_data) => - @successCallback(_data) - error: -> - new Flash("Issues update failed", 'alert') - dataType: "json" - - @sortMergeRequests: (data) -> - sort_mr_url = location.href + "/sort_merge_requests" - - $.ajax - type: "PUT" - url: sort_mr_url - data: data - success: (_data) => - @successCallback(_data) - error: (data) -> - new Flash("Issue update failed", 'alert') - dataType: "json" - - @updateMergeRequest: (li, merge_request_url, data) -> - $.ajax - type: "PUT" - url: merge_request_url - data: data - success: (_data) => - @successCallback(_data, li) - error: (data) -> - new Flash("Issue update failed", 'alert') - dataType: "json" - - @successCallback: (data, element) => - if data.assignee - img_tag = $('<img/>') - img_tag.attr('src', data.assignee.avatar_url) - img_tag.addClass('avatar s16') - $(element).find('.assignee-icon').html(img_tag) - else - $(element).find('.assignee-icon').html('') - - $(element).effect 'highlight' - - constructor: -> - oldMouseStart = $.ui.sortable.prototype._mouseStart - $.ui.sortable.prototype._mouseStart = (event, overrideHandle, noActivation) -> - this._trigger "beforeStart", event, this._uiHash() - oldMouseStart.apply this, [event, overrideHandle, noActivation] - - @bindIssuesSorting() - @bindMergeRequestSorting() - @bindTabsSwitching() - - bindIssuesSorting: -> - $("#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed").sortable( - connectWith: ".issues-sortable-list", - dropOnEmpty: true, - items: "li:not(.ui-sort-disabled)", - beforeStart: (event, ui) -> - $(".issues-sortable-list").css "min-height", ui.item.outerHeight() - stop: (event, ui) -> - $(".issues-sortable-list").css "min-height", "0px" - update: (event, ui) -> - # Prevents sorting from container which element has been removed. - if $(this).find(ui.item).length > 0 - data = $(this).sortable("serialize") - Milestone.sortIssues(data) - - receive: (event, ui) -> - new_state = $(this).data('state') - issue_id = ui.item.data('iid') - issue_url = ui.item.data('url') - - data = switch new_state - when 'ongoing' - "issue[assignee_id]=" + gon.current_user_id - when 'unassigned' - "issue[assignee_id]=" - when 'closed' - "issue[state_event]=close" - - if $(ui.sender).data('state') == "closed" - data += "&issue[state_event]=reopen" - - Milestone.updateIssue(ui.item, issue_url, data) - - ).disableSelection() - - bindTabsSwitching: -> - $('a[data-toggle="tab"]').on 'show.bs.tab', (e) -> - currentTabClass = $(e.target).data('show') - previousTabClass = $(e.relatedTarget).data('show') - - $(previousTabClass).hide() - $(currentTabClass).removeClass('hidden') - $(currentTabClass).show() - - bindMergeRequestSorting: -> - $("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").sortable( - connectWith: ".merge_requests-sortable-list", - dropOnEmpty: true, - items: "li:not(.ui-sort-disabled)", - beforeStart: (event, ui) -> - $(".merge_requests-sortable-list").css "min-height", ui.item.outerHeight() - stop: (event, ui) -> - $(".merge_requests-sortable-list").css "min-height", "0px" - update: (event, ui) -> - data = $(this).sortable("serialize") - Milestone.sortMergeRequests(data) - - receive: (event, ui) -> - new_state = $(this).data('state') - merge_request_id = ui.item.data('iid') - merge_request_url = ui.item.data('url') - - data = switch new_state - when 'ongoing' - "merge_request[assignee_id]=" + gon.current_user_id - when 'unassigned' - "merge_request[assignee_id]=" - when 'closed' - "merge_request[state_event]=close" - - if $(ui.sender).data('state') == "closed" - data += "&merge_request[state_event]=reopen" - - Milestone.updateMergeRequest(ui.item, merge_request_url, data) - - ).disableSelection() diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js new file mode 100644 index 00000000000..a0b65d20c03 --- /dev/null +++ b/app/assets/javascripts/milestone_select.js @@ -0,0 +1,151 @@ +(function() { + this.MilestoneSelect = (function() { + function MilestoneSelect(currentProject) { + var _this; + if (currentProject != null) { + _this = this; + this.currentProject = JSON.parse(currentProject); + } + $('.js-milestone-select').each(function(i, dropdown) { + var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, useId; + $dropdown = $(dropdown); + projectId = $dropdown.data('project-id'); + milestonesUrl = $dropdown.data('milestones'); + issueUpdateURL = $dropdown.data('issueUpdate'); + selectedMilestone = $dropdown.data('selected'); + showNo = $dropdown.data('show-no'); + showAny = $dropdown.data('show-any'); + showUpcoming = $dropdown.data('show-upcoming'); + useId = $dropdown.data('use-id'); + defaultLabel = $dropdown.data('default-label'); + issuableId = $dropdown.data('issuable-id'); + abilityName = $dropdown.data('ability-name'); + $selectbox = $dropdown.closest('.selectbox'); + $block = $selectbox.closest('.block'); + $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); + $value = $block.find('.value'); + $loading = $block.find('.block-loading').fadeOut(); + if (issueUpdateURL) { + milestoneLinkTemplate = _.template('<a href="/<%- namespace %>/<%- path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>'); + milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; + collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- remaining %>" data-placement="left"> <%- title %> </span>'); + } + return $dropdown.glDropdown({ + data: function(term, callback) { + return $.ajax({ + url: milestonesUrl + }).done(function(data) { + var extraOptions; + extraOptions = []; + if (showAny) { + extraOptions.push({ + id: 0, + name: '', + title: 'Any Milestone' + }); + } + if (showNo) { + extraOptions.push({ + id: -1, + name: 'No Milestone', + title: 'No Milestone' + }); + } + if (showUpcoming) { + extraOptions.push({ + id: -2, + name: '#upcoming', + title: 'Upcoming' + }); + } + if (extraOptions.length > 2) { + extraOptions.push('divider'); + } + return callback(extraOptions.concat(data)); + }); + }, + filterable: true, + search: { + fields: ['title'] + }, + selectable: true, + toggleLabel: function(selected) { + if (selected && 'id' in selected) { + return selected.title; + } else { + return defaultLabel; + } + }, + fieldName: $dropdown.data('field-name'), + text: function(milestone) { + return _.escape(milestone.title); + }, + id: function(milestone) { + if (!useId) { + return milestone.name; + } else { + return milestone.id; + } + }, + isSelected: function(milestone) { + return milestone.name === selectedMilestone; + }, + hidden: function() { + $selectbox.hide(); + return $value.css('display', ''); + }, + clicked: function(selected) { + var data, isIssueIndex, isMRIndex, page; + page = $('body').data('page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = (page === page && page === 'projects:merge_requests:index'); + if ($dropdown.hasClass('js-filter-bulk-update')) { + return; + } + if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if (selected.name != null) { + selectedMilestone = selected.name; + } else { + selectedMilestone = ''; + } + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else { + selected = $selectbox.find('input[type="hidden"]').val(); + data = {}; + data[abilityName] = {}; + data[abilityName].milestone_id = selected != null ? selected : null; + $loading.fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + return $.ajax({ + type: 'PUT', + url: issueUpdateURL, + data: data + }).done(function(data) { + $dropdown.trigger('loaded.gl.dropdown'); + $loading.fadeOut(); + $selectbox.hide(); + $value.css('display', ''); + if (data.milestone != null) { + data.milestone.namespace = _this.currentProject.namespace; + data.milestone.path = _this.currentProject.path; + data.milestone.remaining = $.timefor(data.milestone.due_date); + $value.html(milestoneLinkTemplate(data.milestone)); + return $sidebarCollapsedValue.find('span').html(collapsedSidebarLabelTemplate(data.milestone)); + } else { + $value.html(milestoneLinkNoneTemplate); + return $sidebarCollapsedValue.find('span').text('No'); + } + }); + } + } + }); + }); + } + + return MilestoneSelect; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee deleted file mode 100644 index 8ab03ed93ee..00000000000 --- a/app/assets/javascripts/milestone_select.js.coffee +++ /dev/null @@ -1,137 +0,0 @@ -class @MilestoneSelect - constructor: (currentProject) -> - if currentProject? - _this = @ - @currentProject = JSON.parse(currentProject) - $('.js-milestone-select').each (i, dropdown) -> - $dropdown = $(dropdown) - projectId = $dropdown.data('project-id') - milestonesUrl = $dropdown.data('milestones') - issueUpdateURL = $dropdown.data('issueUpdate') - selectedMilestone = $dropdown.data('selected') - showNo = $dropdown.data('show-no') - showAny = $dropdown.data('show-any') - showUpcoming = $dropdown.data('show-upcoming') - useId = $dropdown.data('use-id') - defaultLabel = $dropdown.data('default-label') - issuableId = $dropdown.data('issuable-id') - abilityName = $dropdown.data('ability-name') - $selectbox = $dropdown.closest('.selectbox') - $block = $selectbox.closest('.block') - $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon') - $value = $block.find('.value') - $loading = $block.find('.block-loading').fadeOut() - - if issueUpdateURL - milestoneLinkTemplate = _.template( - '<a href="/<%- namespace %>/<%- path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>' - ) - - milestoneLinkNoneTemplate = '<span class="no-value">None</span>' - - collapsedSidebarLabelTemplate = _.template( - '<span class="has-tooltip" data-container="body" title="<%- remaining %>" data-placement="left"> - <%- title %> - </span>' - ) - - $dropdown.glDropdown( - data: (term, callback) -> - $.ajax( - url: milestonesUrl - ).done (data) -> - extraOptions = [] - if showAny - extraOptions.push( - id: 0 - name: '' - title: 'Any Milestone' - ) - - if showNo - extraOptions.push( - id: -1 - name: 'No Milestone' - title: 'No Milestone' - ) - - if showUpcoming - extraOptions.push( - id: -2 - name: '#upcoming' - title: 'Upcoming' - ) - - if extraOptions.length > 2 - extraOptions.push 'divider' - - callback(extraOptions.concat(data)) - filterable: true - search: - fields: ['title'] - selectable: true - toggleLabel: (selected) -> - if selected && 'id' of selected - selected.title - else - defaultLabel - fieldName: $dropdown.data('field-name') - text: (milestone) -> - _.escape(milestone.title) - id: (milestone) -> - if !useId - milestone.name - else - milestone.id - isSelected: (milestone) -> - milestone.name is selectedMilestone - hidden: -> - $selectbox.hide() - - # display:block overrides the hide-collapse rule - $value.css('display', '') - clicked: (selected) -> - page = $('body').data 'page' - isIssueIndex = page is 'projects:issues:index' - isMRIndex = page is page is 'projects:merge_requests:index' - - if $dropdown.hasClass 'js-filter-bulk-update' - return - - if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex) - if selected.name? - selectedMilestone = selected.name - else - selectedMilestone = '' - Issuable.filterResults $dropdown.closest('form') - else if $dropdown.hasClass('js-filter-submit') - $dropdown.closest('form').submit() - else - selected = $selectbox - .find('input[type="hidden"]') - .val() - data = {} - data[abilityName] = {} - data[abilityName].milestone_id = if selected? then selected else null - $loading - .fadeIn() - $dropdown.trigger('loading.gl.dropdown') - $.ajax( - type: 'PUT' - url: issueUpdateURL - data: data - ).done (data) -> - $dropdown.trigger('loaded.gl.dropdown') - $loading.fadeOut() - $selectbox.hide() - $value.css('display', '') - if data.milestone? - data.milestone.namespace = _this.currentProject.namespace - data.milestone.path = _this.currentProject.path - data.milestone.remaining = $.timefor data.milestone.due_date - $value.html(milestoneLinkTemplate(data.milestone)) - $sidebarCollapsedValue.find('span').html(collapsedSidebarLabelTemplate(data.milestone)) - else - $value.html(milestoneLinkNoneTemplate) - $sidebarCollapsedValue.find('span').text('No') - ) diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js new file mode 100644 index 00000000000..10f4fd106d8 --- /dev/null +++ b/app/assets/javascripts/namespace_select.js @@ -0,0 +1,86 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.NamespaceSelect = (function() { + function NamespaceSelect(opts) { + this.onSelectItem = bind(this.onSelectItem, this); + var fieldName, showAny; + this.dropdown = opts.dropdown; + showAny = true; + fieldName = 'namespace_id'; + if (this.dropdown.attr('data-field-name')) { + fieldName = this.dropdown.data('fieldName'); + } + if (this.dropdown.attr('data-show-any')) { + showAny = this.dropdown.data('showAny'); + } + this.dropdown.glDropdown({ + filterable: true, + selectable: true, + filterRemote: true, + search: { + fields: ['path'] + }, + fieldName: fieldName, + toggleLabel: function(selected) { + if (selected.id == null) { + return selected.text; + } else { + return selected.kind + ": " + selected.path; + } + }, + data: function(term, dataCallback) { + return Api.namespaces(term, function(namespaces) { + var anyNamespace; + if (showAny) { + anyNamespace = { + text: 'Any namespace', + id: null + }; + namespaces.unshift(anyNamespace); + namespaces.splice(1, 0, 'divider'); + } + return dataCallback(namespaces); + }); + }, + text: function(namespace) { + if (namespace.id == null) { + return namespace.text; + } else { + return namespace.kind + ": " + namespace.path; + } + }, + renderRow: this.renderRow, + clicked: this.onSelectItem + }); + } + + NamespaceSelect.prototype.onSelectItem = function(item, el, e) { + return e.preventDefault(); + }; + + return NamespaceSelect; + + })(); + + this.NamespaceSelects = (function() { + function NamespaceSelects(opts) { + var ref; + if (opts == null) { + opts = {}; + } + this.$dropdowns = (ref = opts.$dropdowns) != null ? ref : $('.js-namespace-select'); + this.$dropdowns.each(function(i, dropdown) { + var $dropdown; + $dropdown = $(dropdown); + return new NamespaceSelect({ + dropdown: $dropdown + }); + }); + } + + return NamespaceSelects; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/namespace_select.js.coffee b/app/assets/javascripts/namespace_select.js.coffee deleted file mode 100644 index 3b419dff105..00000000000 --- a/app/assets/javascripts/namespace_select.js.coffee +++ /dev/null @@ -1,56 +0,0 @@ -class @NamespaceSelect - constructor: (opts) -> - { - @dropdown - } = opts - - showAny = true - fieldName = 'namespace_id' - - if @dropdown.attr 'data-field-name' - fieldName = @dropdown.data 'fieldName' - - if @dropdown.attr 'data-show-any' - showAny = @dropdown.data 'showAny' - - @dropdown.glDropdown( - filterable: true - selectable: true - filterRemote: true - search: - fields: ['path'] - fieldName: fieldName - toggleLabel: (selected) -> - return if not selected.id? then selected.text else "#{selected.kind}: #{selected.path}" - data: (term, dataCallback) -> - Api.namespaces term, (namespaces) -> - if showAny - anyNamespace = - text: 'Any namespace' - id: null - - namespaces.unshift(anyNamespace) - namespaces.splice 1, 0, 'divider' - - dataCallback(namespaces) - text: (namespace) -> - return if not namespace.id? then namespace.text else "#{namespace.kind}: #{namespace.path}" - renderRow: @renderRow - clicked: @onSelectItem - ) - - onSelectItem: (item, el, e) => - e.preventDefault() - -class @NamespaceSelects - constructor: (opts = {}) -> - { - @$dropdowns = $('.js-namespace-select') - } = opts - - @$dropdowns.each (i, dropdown) -> - $dropdown = $(dropdown) - - new NamespaceSelect( - dropdown: $dropdown - ) diff --git a/app/assets/javascripts/network/branch-graph.js b/app/assets/javascripts/network/branch-graph.js new file mode 100644 index 00000000000..c0fec1f8607 --- /dev/null +++ b/app/assets/javascripts/network/branch-graph.js @@ -0,0 +1,404 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.BranchGraph = (function() { + function BranchGraph(element1, options1) { + this.element = element1; + this.options = options1; + this.scrollTop = bind(this.scrollTop, this); + this.scrollBottom = bind(this.scrollBottom, this); + this.scrollRight = bind(this.scrollRight, this); + this.scrollLeft = bind(this.scrollLeft, this); + this.scrollUp = bind(this.scrollUp, this); + this.scrollDown = bind(this.scrollDown, this); + this.preparedCommits = {}; + this.mtime = 0; + this.mspace = 0; + this.parents = {}; + this.colors = ["#000"]; + this.offsetX = 150; + this.offsetY = 20; + this.unitTime = 30; + this.unitSpace = 10; + this.prev_start = -1; + this.load(); + } + + BranchGraph.prototype.load = function() { + return $.ajax({ + url: this.options.url, + method: "get", + dataType: "json", + success: $.proxy(function(data) { + $(".loading", this.element).hide(); + this.prepareData(data.days, data.commits); + return this.buildGraph(); + }, this) + }); + }; + + BranchGraph.prototype.prepareData = function(days, commits) { + var c, ch, cw, j, len, ref; + this.days = days; + this.commits = commits; + this.collectParents(); + this.graphHeight = $(this.element).height(); + this.graphWidth = $(this.element).width(); + ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150); + cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300); + this.r = Raphael(this.element.get(0), cw, ch); + this.top = this.r.set(); + this.barHeight = Math.max(this.graphHeight, this.unitTime * this.days.length + 320); + ref = this.commits; + for (j = 0, len = ref.length; j < len; j++) { + c = ref[j]; + if (c.id in this.parents) { + c.isParent = true; + } + this.preparedCommits[c.id] = c; + this.markCommit(c); + } + return this.collectColors(); + }; + + BranchGraph.prototype.collectParents = function() { + var c, j, len, p, ref, results; + ref = this.commits; + results = []; + for (j = 0, len = ref.length; j < len; j++) { + c = ref[j]; + this.mtime = Math.max(this.mtime, c.time); + this.mspace = Math.max(this.mspace, c.space); + results.push((function() { + var l, len1, ref1, results1; + ref1 = c.parents; + results1 = []; + for (l = 0, len1 = ref1.length; l < len1; l++) { + p = ref1[l]; + this.parents[p[0]] = true; + results1.push(this.mspace = Math.max(this.mspace, p[1])); + } + return results1; + }).call(this)); + } + return results; + }; + + BranchGraph.prototype.collectColors = function() { + var k, results; + k = 0; + results = []; + while (k < this.mspace) { + this.colors.push(Raphael.getColor(.8)); + Raphael.getColor(); + Raphael.getColor(); + results.push(k++); + } + return results; + }; + + BranchGraph.prototype.buildGraph = function() { + var cuday, cumonth, day, j, len, mm, r, ref; + r = this.r; + cuday = 0; + cumonth = ""; + r.rect(0, 0, 40, this.barHeight).attr({ + fill: "#222" + }); + r.rect(40, 0, 30, this.barHeight).attr({ + fill: "#444" + }); + ref = this.days; + for (mm = j = 0, len = ref.length; j < len; mm = ++j) { + day = ref[mm]; + if (cuday !== day[0] || cumonth !== day[1]) { + r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({ + font: "12px Monaco, monospace", + fill: "#BBB" + }); + cuday = day[0]; + } + if (cumonth !== day[1]) { + r.text(20, this.offsetY + this.unitTime * mm, day[1]).attr({ + font: "12px Monaco, monospace", + fill: "#EEE" + }); + cumonth = day[1]; + } + } + this.renderPartialGraph(); + return this.bindEvents(); + }; + + BranchGraph.prototype.renderPartialGraph = function() { + var commit, end, i, isGraphEdge, start, x, y; + start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10; + if (start < 0) { + isGraphEdge = true; + start = 0; + } + end = start + 40; + if (this.commits.length < end) { + isGraphEdge = true; + end = this.commits.length; + } + if (this.prev_start === -1 || Math.abs(this.prev_start - start) > 10 || isGraphEdge) { + i = start; + this.prev_start = start; + while (i < end) { + commit = this.commits[i]; + i += 1; + if (commit.hasDrawn !== true) { + x = this.offsetX + this.unitSpace * (this.mspace - commit.space); + y = this.offsetY + this.unitTime * commit.time; + this.drawDot(x, y, commit); + this.drawLines(x, y, commit); + this.appendLabel(x, y, commit); + this.appendAnchor(x, y, commit); + commit.hasDrawn = true; + } + } + return this.top.toFront(); + } + }; + + BranchGraph.prototype.bindEvents = function() { + var element; + element = this.element; + return $(element).scroll((function(_this) { + return function(event) { + return _this.renderPartialGraph(); + }; + })(this)); + }; + + BranchGraph.prototype.scrollDown = function() { + this.element.scrollTop(this.element.scrollTop() + 50); + return this.renderPartialGraph(); + }; + + BranchGraph.prototype.scrollUp = function() { + this.element.scrollTop(this.element.scrollTop() - 50); + return this.renderPartialGraph(); + }; + + BranchGraph.prototype.scrollLeft = function() { + this.element.scrollLeft(this.element.scrollLeft() - 50); + return this.renderPartialGraph(); + }; + + BranchGraph.prototype.scrollRight = function() { + this.element.scrollLeft(this.element.scrollLeft() + 50); + return this.renderPartialGraph(); + }; + + BranchGraph.prototype.scrollBottom = function() { + return this.element.scrollTop(this.element.find('svg').height()); + }; + + BranchGraph.prototype.scrollTop = function() { + return this.element.scrollTop(0); + }; + + BranchGraph.prototype.appendLabel = function(x, y, commit) { + var label, r, rect, shortrefs, text, textbox, triangle; + if (!commit.refs) { + return; + } + r = this.r; + shortrefs = commit.refs; + if (shortrefs.length > 17) { + shortrefs = shortrefs.substr(0, 15) + "…"; + } + text = r.text(x + 4, y, shortrefs).attr({ + "text-anchor": "start", + font: "10px Monaco, monospace", + fill: "#FFF", + title: commit.refs + }); + textbox = text.getBBox(); + rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({ + fill: "#000", + "fill-opacity": .5, + stroke: "none" + }); + triangle = r.path(["M", x - 5, y, "L", x - 15, y - 4, "L", x - 15, y + 4, "Z"]).attr({ + fill: "#000", + "fill-opacity": .5, + stroke: "none" + }); + label = r.set(rect, text); + label.transform(["t", -rect.getBBox().width - 15, 0]); + return text.toFront(); + }; + + BranchGraph.prototype.appendAnchor = function(x, y, commit) { + var anchor, options, r, top; + r = this.r; + top = this.top; + options = this.options; + anchor = r.circle(x, y, 10).attr({ + fill: "#000", + opacity: 0, + cursor: "pointer" + }).click(function() { + return window.open(options.commit_url.replace("%s", commit.id), "_blank"); + }).hover(function() { + this.tooltip = r.commitTooltip(x + 5, y, commit); + return top.push(this.tooltip.insertBefore(this)); + }, function() { + return this.tooltip && this.tooltip.remove() && delete this.tooltip; + }); + return top.push(anchor); + }; + + BranchGraph.prototype.drawDot = function(x, y, commit) { + var avatar_box_x, avatar_box_y, r; + r = this.r; + r.circle(x, y, 3).attr({ + fill: this.colors[commit.space], + stroke: "none" + }); + avatar_box_x = this.offsetX + this.unitSpace * this.mspace + 10; + avatar_box_y = y - 10; + r.rect(avatar_box_x, avatar_box_y, 20, 20).attr({ + stroke: this.colors[commit.space], + "stroke-width": 2 + }); + r.image(commit.author.icon, avatar_box_x, avatar_box_y, 20, 20); + return r.text(this.offsetX + this.unitSpace * this.mspace + 35, y, commit.message.split("\n")[0]).attr({ + "text-anchor": "start", + font: "14px Monaco, monospace" + }); + }; + + BranchGraph.prototype.drawLines = function(x, y, commit) { + var arrow, color, i, j, len, offset, parent, parentCommit, parentX1, parentX2, parentY, r, ref, results, route; + r = this.r; + ref = commit.parents; + results = []; + for (i = j = 0, len = ref.length; j < len; i = ++j) { + parent = ref[i]; + parentCommit = this.preparedCommits[parent[0]]; + parentY = this.offsetY + this.unitTime * parentCommit.time; + parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); + parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); + if (parentCommit.space <= commit.space) { + color = this.colors[commit.space]; + } else { + color = this.colors[parentCommit.space]; + } + if (parent[1] === commit.space) { + offset = [0, 5]; + arrow = "l-2,5,4,0,-2,-5,0,5"; + } else if (parent[1] < commit.space) { + offset = [3, 3]; + arrow = "l5,0,-2,4,-3,-4,4,2"; + } else { + offset = [-3, 3]; + arrow = "l-5,0,2,4,3,-4,-4,2"; + } + route = ["M", x + offset[0], y + offset[1]]; + if (i > 0) { + route.push(arrow); + } + if (commit.space !== parentCommit.space || commit.space !== parent[1]) { + route.push("L", parentX2, y + 10, "L", parentX2, parentY - 5); + } + route.push("L", parentX1, parentY); + results.push(r.path(route).attr({ + stroke: color, + "stroke-width": 2 + })); + } + return results; + }; + + BranchGraph.prototype.markCommit = function(commit) { + var r, x, y; + if (commit.id === this.options.commit_id) { + r = this.r; + x = this.offsetX + this.unitSpace * (this.mspace - commit.space); + y = this.offsetY + this.unitTime * commit.time; + r.path(["M", x + 5, y, "L", x + 15, y + 4, "L", x + 15, y - 4, "Z"]).attr({ + fill: "#000", + "fill-opacity": .5, + stroke: "none" + }); + return this.element.scrollTop(y - this.graphHeight / 2); + } + }; + + return BranchGraph; + + })(); + + Raphael.prototype.commitTooltip = function(x, y, commit) { + var boxHeight, boxWidth, icon, idText, messageText, nameText, rect, textSet, tooltip; + boxWidth = 300; + boxHeight = 200; + icon = this.image(gon.relative_url_root + commit.author.icon, x, y, 20, 20); + nameText = this.text(x + 25, y + 10, commit.author.name); + idText = this.text(x, y + 35, commit.id); + messageText = this.text(x, y + 50, commit.message); + textSet = this.set(icon, nameText, idText, messageText).attr({ + "text-anchor": "start", + font: "12px Monaco, monospace" + }); + nameText.attr({ + font: "14px Arial", + "font-weight": "bold" + }); + idText.attr({ + fill: "#AAA" + }); + this.textWrap(messageText, boxWidth - 50); + rect = this.rect(x - 10, y - 10, boxWidth, 100, 4).attr({ + fill: "#FFF", + stroke: "#000", + "stroke-linecap": "round", + "stroke-width": 2 + }); + tooltip = this.set(rect, textSet); + rect.attr({ + height: tooltip.getBBox().height + 10, + width: tooltip.getBBox().width + 10 + }); + tooltip.transform(["t", 20, 20]); + return tooltip; + }; + + Raphael.prototype.textWrap = function(t, width) { + var abc, b, content, h, j, len, letterWidth, s, word, words, x; + content = t.attr("text"); + abc = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + t.attr({ + text: abc + }); + letterWidth = t.getBBox().width / abc.length; + t.attr({ + text: content + }); + words = content.split(" "); + x = 0; + s = []; + for (j = 0, len = words.length; j < len; j++) { + word = words[j]; + if (x + (word.length * letterWidth) > width) { + s.push("\n"); + x = 0; + } + x += word.length * letterWidth; + s.push(word + " "); + } + t.attr({ + text: s.join("") + }); + b = t.getBBox(); + h = Math.abs(b.y2) - Math.abs(b.y) + 1; + return t.attr({ + y: b.y + h + }); + }; + +}).call(this); diff --git a/app/assets/javascripts/network/branch-graph.js.coffee b/app/assets/javascripts/network/branch-graph.js.coffee deleted file mode 100644 index f2fd2a775a4..00000000000 --- a/app/assets/javascripts/network/branch-graph.js.coffee +++ /dev/null @@ -1,340 +0,0 @@ -class @BranchGraph - constructor: (@element, @options) -> - @preparedCommits = {} - @mtime = 0 - @mspace = 0 - @parents = {} - @colors = ["#000"] - @offsetX = 150 - @offsetY = 20 - @unitTime = 30 - @unitSpace = 10 - @prev_start = -1 - @load() - - load: -> - $.ajax - url: @options.url - method: "get" - dataType: "json" - success: $.proxy((data) -> - $(".loading", @element).hide() - @prepareData data.days, data.commits - @buildGraph() - , this) - - prepareData: (@days, @commits) -> - @collectParents() - @graphHeight = $(@element).height() - @graphWidth = $(@element).width() - ch = Math.max(@graphHeight, @offsetY + @unitTime * @mtime + 150) - cw = Math.max(@graphWidth, @offsetX + @unitSpace * @mspace + 300) - @r = Raphael(@element.get(0), cw, ch) - @top = @r.set() - @barHeight = Math.max(@graphHeight, @unitTime * @days.length + 320) - - for c in @commits - c.isParent = true if c.id of @parents - @preparedCommits[c.id] = c - @markCommit(c) - - @collectColors() - - collectParents: -> - for c in @commits - @mtime = Math.max(@mtime, c.time) - @mspace = Math.max(@mspace, c.space) - for p in c.parents - @parents[p[0]] = true - @mspace = Math.max(@mspace, p[1]) - - collectColors: -> - k = 0 - while k < @mspace - @colors.push Raphael.getColor(.8) - # Skipping a few colors in the spectrum to get more contrast between colors - Raphael.getColor() - Raphael.getColor() - k++ - - buildGraph: -> - r = @r - cuday = 0 - cumonth = "" - - r.rect(0, 0, 40, @barHeight).attr fill: "#222" - r.rect(40, 0, 30, @barHeight).attr fill: "#444" - - for day, mm in @days - if cuday isnt day[0] || cumonth isnt day[1] - # Dates - r.text(55, @offsetY + @unitTime * mm, day[0]) - .attr( - font: "12px Monaco, monospace" - fill: "#BBB" - ) - cuday = day[0] - - if cumonth isnt day[1] - # Months - r.text(20, @offsetY + @unitTime * mm, day[1]) - .attr( - font: "12px Monaco, monospace" - fill: "#EEE" - ) - cumonth = day[1] - - @renderPartialGraph() - - @bindEvents() - - renderPartialGraph: -> - start = Math.floor((@element.scrollTop() - @offsetY) / @unitTime) - 10 - if start < 0 - isGraphEdge = true - start = 0 - end = start + 40 - if @commits.length < end - isGraphEdge = true - end = @commits.length - - if @prev_start == -1 or Math.abs(@prev_start - start) > 10 or isGraphEdge - i = start - - @prev_start = start - - while i < end - commit = @commits[i] - i += 1 - - if commit.hasDrawn isnt true - x = @offsetX + @unitSpace * (@mspace - commit.space) - y = @offsetY + @unitTime * commit.time - - @drawDot(x, y, commit) - - @drawLines(x, y, commit) - - @appendLabel(x, y, commit) - - @appendAnchor(x, y, commit) - - commit.hasDrawn = true - - @top.toFront() - - bindEvents: -> - element = @element - - $(element).scroll (event) => - @renderPartialGraph() - - scrollDown: => - @element.scrollTop @element.scrollTop() + 50 - @renderPartialGraph() - - scrollUp: => - @element.scrollTop @element.scrollTop() - 50 - @renderPartialGraph() - - scrollLeft: => - @element.scrollLeft @element.scrollLeft() - 50 - @renderPartialGraph() - - scrollRight: => - @element.scrollLeft @element.scrollLeft() + 50 - @renderPartialGraph() - - scrollBottom: => - @element.scrollTop @element.find('svg').height() - - scrollTop: => - @element.scrollTop 0 - - appendLabel: (x, y, commit) -> - return unless commit.refs - - r = @r - shortrefs = commit.refs - # Truncate if longer than 15 chars - shortrefs = shortrefs.substr(0, 15) + "…" if shortrefs.length > 17 - text = r.text(x + 4, y, shortrefs).attr( - "text-anchor": "start" - font: "10px Monaco, monospace" - fill: "#FFF" - title: commit.refs - ) - textbox = text.getBBox() - # Create rectangle based on the size of the textbox - rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - triangle = r.path(["M", x - 5, y, "L", x - 15, y - 4, "L", x - 15, y + 4, "Z"]).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - - label = r.set(rect, text) - label.transform(["t", -rect.getBBox().width - 15, 0]) - - # Set text to front - text.toFront() - - appendAnchor: (x, y, commit) -> - r = @r - top = @top - options = @options - anchor = r.circle(x, y, 10).attr( - fill: "#000" - opacity: 0 - cursor: "pointer" - ).click(-> - window.open options.commit_url.replace("%s", commit.id), "_blank" - ).hover(-> - @tooltip = r.commitTooltip(x + 5, y, commit) - top.push @tooltip.insertBefore(this) - , -> - @tooltip and @tooltip.remove() and delete @tooltip - ) - top.push anchor - - drawDot: (x, y, commit) -> - r = @r - r.circle(x, y, 3).attr( - fill: @colors[commit.space] - stroke: "none" - ) - - avatar_box_x = @offsetX + @unitSpace * @mspace + 10 - avatar_box_y = y - 10 - r.rect(avatar_box_x, avatar_box_y, 20, 20).attr( - stroke: @colors[commit.space] - "stroke-width": 2 - ) - r.image(commit.author.icon, avatar_box_x, avatar_box_y, 20, 20) - r.text(@offsetX + @unitSpace * @mspace + 35, y, commit.message.split("\n")[0]).attr( - "text-anchor": "start" - font: "14px Monaco, monospace" - ) - - drawLines: (x, y, commit) -> - r = @r - for parent, i in commit.parents - parentCommit = @preparedCommits[parent[0]] - parentY = @offsetY + @unitTime * parentCommit.time - parentX1 = @offsetX + @unitSpace * (@mspace - parentCommit.space) - parentX2 = @offsetX + @unitSpace * (@mspace - parent[1]) - - # Set line color - if parentCommit.space <= commit.space - color = @colors[commit.space] - - else - color = @colors[parentCommit.space] - - # Build line shape - if parent[1] is commit.space - offset = [0, 5] - arrow = "l-2,5,4,0,-2,-5,0,5" - - else if parent[1] < commit.space - offset = [3, 3] - arrow = "l5,0,-2,4,-3,-4,4,2" - - else - offset = [-3, 3] - arrow = "l-5,0,2,4,3,-4,-4,2" - - # Start point - route = ["M", x + offset[0], y + offset[1]] - - # Add arrow if not first parent - if i > 0 - route.push(arrow) - - # Circumvent if overlap - if commit.space isnt parentCommit.space or commit.space isnt parent[1] - route.push( - "L", parentX2, y + 10, - "L", parentX2, parentY - 5, - ) - - # End point - route.push("L", parentX1, parentY) - - r - .path(route) - .attr( - stroke: color - "stroke-width": 2) - - markCommit: (commit) -> - if commit.id is @options.commit_id - r = @r - x = @offsetX + @unitSpace * (@mspace - commit.space) - y = @offsetY + @unitTime * commit.time - r.path(["M", x + 5, y, "L", x + 15, y + 4, "L", x + 15, y - 4, "Z"]).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - # Displayed in the center - @element.scrollTop(y - @graphHeight / 2) - -Raphael::commitTooltip = (x, y, commit) -> - boxWidth = 300 - boxHeight = 200 - icon = @image(gon.relative_url_root + commit.author.icon, x, y, 20, 20) - nameText = @text(x + 25, y + 10, commit.author.name) - idText = @text(x, y + 35, commit.id) - messageText = @text(x, y + 50, commit.message) - textSet = @set(icon, nameText, idText, messageText).attr( - "text-anchor": "start" - font: "12px Monaco, monospace" - ) - nameText.attr( - font: "14px Arial" - "font-weight": "bold" - ) - - idText.attr fill: "#AAA" - @textWrap messageText, boxWidth - 50 - rect = @rect(x - 10, y - 10, boxWidth, 100, 4).attr( - fill: "#FFF" - stroke: "#000" - "stroke-linecap": "round" - "stroke-width": 2 - ) - tooltip = @set(rect, textSet) - rect.attr( - height: tooltip.getBBox().height + 10 - width: tooltip.getBBox().width + 10 - ) - - tooltip.transform ["t", 20, 20] - tooltip - -Raphael::textWrap = (t, width) -> - content = t.attr("text") - abc = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - t.attr text: abc - letterWidth = t.getBBox().width / abc.length - t.attr text: content - words = content.split(" ") - x = 0 - s = [] - - for word in words - if x + (word.length * letterWidth) > width - s.push "\n" - x = 0 - x += word.length * letterWidth - s.push word + " " - - t.attr text: s.join("") - b = t.getBBox() - h = Math.abs(b.y2) - Math.abs(b.y) + 1 - t.attr y: b.y + h diff --git a/app/assets/javascripts/network/network.js b/app/assets/javascripts/network/network.js new file mode 100644 index 00000000000..7baebcd100a --- /dev/null +++ b/app/assets/javascripts/network/network.js @@ -0,0 +1,19 @@ +(function() { + this.Network = (function() { + function Network(opts) { + var vph; + $("#filter_ref").click(function() { + return $(this).closest('form').submit(); + }); + this.branch_graph = new BranchGraph($(".network-graph"), opts); + vph = $(window).height() - 250; + $('.network-graph').css({ + 'height': vph + 'px' + }); + } + + return Network; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/network/network.js.coffee b/app/assets/javascripts/network/network.js.coffee deleted file mode 100644 index f4ef07a50a7..00000000000 --- a/app/assets/javascripts/network/network.js.coffee +++ /dev/null @@ -1,9 +0,0 @@ -class @Network - constructor: (opts) -> - $("#filter_ref").click -> - $(this).closest('form').submit() - - @branch_graph = new BranchGraph($(".network-graph"), opts) - - vph = $(window).height() - 250 - $('.network-graph').css 'height': (vph + 'px') diff --git a/app/assets/javascripts/network/network_bundle.js b/app/assets/javascripts/network/network_bundle.js new file mode 100644 index 00000000000..6a7422a7755 --- /dev/null +++ b/app/assets/javascripts/network/network_bundle.js @@ -0,0 +1,16 @@ + +/*= require_tree . */ + +(function() { + $(function() { + var network_graph; + network_graph = new Network({ + url: $(".network-graph").attr('data-url'), + commit_url: $(".network-graph").attr('data-commit-url'), + ref: $(".network-graph").attr('data-ref'), + commit_id: $(".network-graph").attr('data-commit-id') + }); + return new ShortcutsNetwork(network_graph.branch_graph); + }); + +}).call(this); diff --git a/app/assets/javascripts/network/network_bundle.js.coffee b/app/assets/javascripts/network/network_bundle.js.coffee deleted file mode 100644 index f75f63869c5..00000000000 --- a/app/assets/javascripts/network/network_bundle.js.coffee +++ /dev/null @@ -1,17 +0,0 @@ -# This is a manifest file that'll be compiled into including all the files listed below. -# Add new JavaScript/Coffee code in separate files in this directory and they'll automatically -# be included in the compiled file accessible from http://example.com/assets/application.js -# It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -# the compiled file. -# -#= require_tree . - -$ -> - network_graph = new Network({ - url: $(".network-graph").attr('data-url'), - commit_url: $(".network-graph").attr('data-commit-url'), - ref: $(".network-graph").attr('data-ref'), - commit_id: $(".network-graph").attr('data-commit-id') - }) - - new ShortcutsNetwork(network_graph.branch_graph) diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js new file mode 100644 index 00000000000..20aa2fced27 --- /dev/null +++ b/app/assets/javascripts/new_branch_form.js @@ -0,0 +1,104 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, + indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; + + this.NewBranchForm = (function() { + function NewBranchForm(form, availableRefs) { + this.validate = bind(this.validate, this); + this.branchNameError = form.find('.js-branch-name-error'); + this.name = form.find('.js-branch-name'); + this.ref = form.find('#ref'); + this.setupAvailableRefs(availableRefs); + this.setupRestrictions(); + this.addBinding(); + this.init(); + } + + NewBranchForm.prototype.addBinding = function() { + return this.name.on('blur', this.validate); + }; + + NewBranchForm.prototype.init = function() { + if (this.name.val().length > 0) { + return this.name.trigger('blur'); + } + }; + + NewBranchForm.prototype.setupAvailableRefs = function(availableRefs) { + return this.ref.autocomplete({ + source: availableRefs, + minLength: 1 + }); + }; + + NewBranchForm.prototype.setupRestrictions = function() { + var endsWith, invalid, single, startsWith; + startsWith = { + pattern: /^(\/|\.)/g, + prefix: "can't start with", + conjunction: "or" + }; + endsWith = { + pattern: /(\/|\.|\.lock)$/g, + prefix: "can't end in", + conjunction: "or" + }; + invalid = { + pattern: /(\s|~|\^|:|\?|\*|\[|\\|\.\.|@\{|\/{2,}){1}/g, + prefix: "can't contain", + conjunction: ", " + }; + single = { + pattern: /^@+$/g, + prefix: "can't be", + conjunction: "or" + }; + return this.restrictions = [startsWith, invalid, endsWith, single]; + }; + + NewBranchForm.prototype.validate = function() { + var errorMessage, errors, formatter, unique, validator; + this.branchNameError.empty(); + unique = function(values, value) { + if (indexOf.call(values, value) < 0) { + values.push(value); + } + return values; + }; + formatter = function(values, restriction) { + var formatted; + formatted = values.map(function(value) { + switch (false) { + case !/\s/.test(value): + return 'spaces'; + case !/\/{2,}/g.test(value): + return 'consecutive slashes'; + default: + return "'" + value + "'"; + } + }); + return restriction.prefix + " " + (formatted.join(restriction.conjunction)); + }; + validator = (function(_this) { + return function(errors, restriction) { + var matched; + matched = _this.name.val().match(restriction.pattern); + if (matched) { + return errors.concat(formatter(matched.reduce(unique, []), restriction)); + } else { + return errors; + } + }; + })(this); + errors = this.restrictions.reduce(validator, []); + if (errors.length > 0) { + errorMessage = $("<span/>").text(errors.join(', ')); + return this.branchNameError.append(errorMessage); + } + }; + + return NewBranchForm; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/new_branch_form.js.coffee b/app/assets/javascripts/new_branch_form.js.coffee deleted file mode 100644 index 4b350854f78..00000000000 --- a/app/assets/javascripts/new_branch_form.js.coffee +++ /dev/null @@ -1,78 +0,0 @@ -class @NewBranchForm - constructor: (form, availableRefs) -> - @branchNameError = form.find('.js-branch-name-error') - @name = form.find('.js-branch-name') - @ref = form.find('#ref') - - @setupAvailableRefs(availableRefs) - @setupRestrictions() - @addBinding() - @init() - - addBinding: -> - @name.on 'blur', @validate - - init: -> - @name.trigger 'blur' if @name.val().length > 0 - - setupAvailableRefs: (availableRefs) -> - @ref.autocomplete - source: availableRefs, - minLength: 1 - - setupRestrictions: -> - startsWith = { - pattern: /^(\/|\.)/g, - prefix: "can't start with", - conjunction: "or" - } - - endsWith = { - pattern: /(\/|\.|\.lock)$/g, - prefix: "can't end in", - conjunction: "or" - } - - invalid = { - pattern: /(\s|~|\^|:|\?|\*|\[|\\|\.\.|@\{|\/{2,}){1}/g - prefix: "can't contain", - conjunction: ", " - } - - single = { - pattern: /^@+$/g - prefix: "can't be", - conjunction: "or" - } - - @restrictions = [startsWith, invalid, endsWith, single] - - validate: => - @branchNameError.empty() - - unique = (values, value) -> - values.push(value) unless value in values - values - - formatter = (values, restriction) -> - formatted = values.map (value) -> - switch - when /\s/.test value then 'spaces' - when /\/{2,}/g.test value then 'consecutive slashes' - else "'#{value}'" - - "#{restriction.prefix} #{formatted.join(restriction.conjunction)}" - - validator = (errors, restriction) => - matched = @name.val().match(restriction.pattern) - - if matched - errors.concat formatter(matched.reduce(unique, []), restriction) - else - errors - - errors = @restrictions.reduce validator, [] - - if errors.length > 0 - errorMessage = $("<span/>").text(errors.join(', ')) - @branchNameError.append(errorMessage) diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js new file mode 100644 index 00000000000..21bf8867f7b --- /dev/null +++ b/app/assets/javascripts/new_commit_form.js @@ -0,0 +1,34 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.NewCommitForm = (function() { + function NewCommitForm(form) { + this.renderDestination = bind(this.renderDestination, this); + this.newBranch = form.find('.js-target-branch'); + this.originalBranch = form.find('.js-original-branch'); + this.createMergeRequest = form.find('.js-create-merge-request'); + this.createMergeRequestContainer = form.find('.js-create-merge-request-container'); + this.renderDestination(); + this.newBranch.keyup(this.renderDestination); + } + + NewCommitForm.prototype.renderDestination = function() { + var different; + different = this.newBranch.val() !== this.originalBranch.val(); + if (different) { + this.createMergeRequestContainer.show(); + if (!this.wasDifferent) { + this.createMergeRequest.prop('checked', true); + } + } else { + this.createMergeRequestContainer.hide(); + this.createMergeRequest.prop('checked', false); + } + return this.wasDifferent = different; + }; + + return NewCommitForm; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/new_commit_form.js.coffee b/app/assets/javascripts/new_commit_form.js.coffee deleted file mode 100644 index 03f0f51acfa..00000000000 --- a/app/assets/javascripts/new_commit_form.js.coffee +++ /dev/null @@ -1,21 +0,0 @@ -class @NewCommitForm - constructor: (form) -> - @newBranch = form.find('.js-target-branch') - @originalBranch = form.find('.js-original-branch') - @createMergeRequest = form.find('.js-create-merge-request') - @createMergeRequestContainer = form.find('.js-create-merge-request-container') - - @renderDestination() - @newBranch.keyup @renderDestination - - renderDestination: => - different = @newBranch.val() != @originalBranch.val() - - if different - @createMergeRequestContainer.show() - @createMergeRequest.prop('checked', true) unless @wasDifferent - else - @createMergeRequestContainer.hide() - @createMergeRequest.prop('checked', false) - - @wasDifferent = different diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js new file mode 100644 index 00000000000..9ece474d994 --- /dev/null +++ b/app/assets/javascripts/notes.js @@ -0,0 +1,732 @@ + +/*= require autosave */ + + +/*= require autosize */ + + +/*= require dropzone */ + + +/*= require dropzone_input */ + + +/*= require gfm_auto_complete */ + + +/*= require jquery.atwho */ + + +/*= require task_list */ + +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Notes = (function() { + var isMetaKey; + + 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.visibilityChange = bind(this.visibilityChange, this); + this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this); + this.addDiffNote = bind(this.addDiffNote, this); + this.setupDiscussionNoteForm = bind(this.setupDiscussionNoteForm, this); + this.replyToDiscussionNote = bind(this.replyToDiscussionNote, this); + this.removeNote = bind(this.removeNote, this); + this.cancelEdit = bind(this.cancelEdit, this); + this.updateNote = bind(this.updateNote, this); + this.addDiscussionNote = bind(this.addDiscussionNote, this); + this.addNoteError = bind(this.addNoteError, this); + this.addNote = bind(this.addNote, this); + this.resetMainTargetForm = bind(this.resetMainTargetForm, this); + this.refresh = bind(this.refresh, this); + this.keydownNoteText = bind(this.keydownNoteText, this); + this.notes_url = notes_url; + this.note_ids = note_ids; + this.last_fetched_at = last_fetched_at; + this.view = view; + 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(); + this.setupMainTargetNoteForm(); + this.initTaskList(); + } + + Notes.prototype.addBinding = function() { + $(document).on("ajax:success", ".js-main-target-form", this.addNote); + $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); + $(document).on("ajax:error", ".js-main-target-form", this.addNoteError); + $(document).on("ajax:success", "form.edit-note", this.updateNote); + $(document).on("click", ".js-note-edit", this.showEditForm); + $(document).on("click", ".note-edit-cancel", this.cancelEdit); + $(document).on("click", ".js-comment-button", this.updateCloseButton); + $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); + $(document).on("click", ".js-note-delete", this.removeNote); + $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); + $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); + $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); + $(document).on("click", ".js-note-discard", this.resetMainTargetForm); + $(document).on("change", ".js-note-attachment-input", this.updateFormAttachment); + $(document).on("click", ".js-discussion-reply-button", this.replyToDiscussionNote); + $(document).on("click", ".js-add-diff-note-button", this.addDiffNote); + $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm); + $(document).on("visibilitychange", this.visibilityChange); + $(document).on("issuable:change", this.refresh); + 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("click", ".js-note-target-reopen"); + $(document).off("click", ".js-note-target-close"); + $(document).off("click", ".js-note-discard"); + $(document).off("keydown", ".js-note-text"); + $('.note .js-task-list-container').taskList('disable'); + return $(document).off('tasklist:changed', '.note .js-task-list-container'); + }; + + Notes.prototype.keydownNoteText = function(e) { + var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; + if (isMetaKey(e)) { + return; + } + $textarea = $(e.target); + switch (e.which) { + case 38: + if ($textarea.val() !== '') { + return; + } + myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last"); + if (myLastNote.length) { + myLastNoteEditBtn = myLastNote.find('.js-note-edit'); + return myLastNoteEditBtn.trigger('click', [true, myLastNote]); + } + break; + case 27: + discussionNoteForm = $textarea.closest('.js-discussion-note-form'); + if (discussionNoteForm.length) { + if ($textarea.val() !== '') { + if (!confirm('Are you sure you want to cancel creating this comment?')) { + return; + } + } + this.removeDiscussionNoteForm(discussionNoteForm); + return; + } + editNote = $textarea.closest('.note'); + if (editNote.length) { + originalText = $textarea.closest('form').data('original-note'); + newText = $textarea.val(); + if (originalText !== newText) { + if (!confirm('Are you sure you want to cancel editing this comment?')) { + return; + } + } + return this.removeNoteEditForm(editNote); + } + } + }; + + isMetaKey = function(e) { + return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; + }; + + Notes.prototype.initRefresh = function() { + clearInterval(Notes.interval); + return Notes.interval = setInterval((function(_this) { + return function() { + return _this.refresh(); + }; + })(this), this.pollingInterval); + }; + + Notes.prototype.refresh = function() { + if (!document.hidden && document.URL.indexOf(this.noteable_url) === 0) { + return this.getContent(); + } + }; + + Notes.prototype.getContent = function() { + if (this.refreshing) { + return; + } + this.refreshing = true; + return $.ajax({ + url: this.notes_url, + data: "last_fetched_at=" + this.last_fetched_at, + dataType: "json", + success: (function(_this) { + return function(data) { + var notes; + notes = data.notes; + _this.last_fetched_at = data.last_fetched_at; + _this.setPollingInterval(data.notes.length); + return $.each(notes, function(i, note) { + if (note.discussion_html != null) { + return _this.renderDiscussionNote(note); + } else { + return _this.renderNote(note); + } + }); + }; + })(this) + }).always((function(_this) { + return function() { + return _this.refreshing = false; + }; + })(this)); + }; + + + /* + Increase @pollingInterval up to 120 seconds on every function call, + if `shouldReset` has a truthy value, 'null' or 'undefined' the variable + will reset to @basePollingInterval. + + Note: this function is used to gradually increase the polling interval + if there aren't new notes coming from the server + */ + + Notes.prototype.setPollingInterval = function(shouldReset) { + var nthInterval; + if (shouldReset == null) { + shouldReset = true; + } + nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1); + if (shouldReset) { + this.pollingInterval = this.basePollingInterval; + } else if (this.pollingInterval < nthInterval) { + this.pollingInterval *= 2; + } + return this.initRefresh(); + }; + + + /* + Render note in main comments area. + + Note: for rendering inline notes use renderDiscussionNote + */ + + Notes.prototype.renderNote = function(note) { + var $notesList, votesBlock; + if (!note.valid) { + if (note.award) { + new Flash('You have already awarded this emoji!', 'alert'); + } + return; + } + if (note.award) { + votesBlock = $('.js-awards-block').eq(0); + gl.awardsHandler.addAwardToEmojiBar(votesBlock, note.name); + return gl.awardsHandler.scrollToAwards(); + } else if (this.isNewNote(note)) { + this.note_ids.push(note.id); + $notesList = $('ul.main-notes-list'); + $notesList.append(note.html).syntaxHighlight(); + gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); + this.initTaskList(); + return this.updateNotesCount(1); + } + }; + + + /* + Check if note does not exists on page + */ + + Notes.prototype.isNewNote = function(note) { + return $.inArray(note.id, this.note_ids) === -1; + }; + + Notes.prototype.isParallelView = function() { + return this.view === 'parallel'; + }; + + + /* + Render note in discussion area. + + Note: for rendering inline notes use renderDiscussionNote + */ + + Notes.prototype.renderDiscussionNote = function(note) { + var discussionContainer, form, note_html, row; + if (!this.isNewNote(note)) { + return; + } + this.note_ids.push(note.id); + form = $("#new-discussion-note-form-" + note.discussion_id); + if ((note.original_discussion_id != null) && form.length === 0) { + form = $("#new-discussion-note-form-" + note.original_discussion_id); + } + row = form.closest("tr"); + note_html = $(note.html); + note_html.syntaxHighlight(); + discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); + if ((note.original_discussion_id != null) && discussionContainer.length === 0) { + discussionContainer = $(".notes[data-discussion-id='" + note.original_discussion_id + "']"); + } + if (discussionContainer.length === 0) { + row.after(note.diff_discussion_html); + row.next().find(".note").remove(); + discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); + discussionContainer.append(note_html); + if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) { + $('ul.main-notes-list').append(note.discussion_html).syntaxHighlight(); + } + } else { + discussionContainer.append(note_html); + } + gl.utils.localTimeAgo($('.js-timeago', note_html), false); + return this.updateNotesCount(1); + }; + + + /* + Called in response the main target form has been successfully submitted. + + Removes any errors. + Resets text and preview. + Resets buttons. + */ + + Notes.prototype.resetMainTargetForm = function(e) { + var form; + form = $(".js-main-target-form"); + form.find(".js-errors").remove(); + form.find(".js-md-write-button").click(); + form.find(".js-note-text").val("").trigger("input"); + form.find(".js-note-text").data("autosave").reset(); + return this.updateTargetButtons(e); + }; + + Notes.prototype.reenableTargetFormSubmitButton = function() { + var form; + form = $(".js-main-target-form"); + return form.find(".js-note-text").trigger("input"); + }; + + + /* + Shows the main form and does some setup on it. + + Sets some hidden fields in the form. + */ + + Notes.prototype.setupMainTargetNoteForm = function() { + var form; + form = $(".js-new-note-form"); + this.formClone = form.clone(); + this.setupNoteForm(form); + form.removeClass("js-new-note-form"); + form.addClass("js-main-target-form"); + form.find("#note_line_code").remove(); + form.find("#note_position").remove(); + form.find("#note_type").remove(); + return this.parentTimeline = form.parents('.timeline'); + }; + + + /* + General note form setup. + + deactivates the submit button when text is empty + hides the preview button when text is empty + setup GFM auto complete + show the form + */ + + Notes.prototype.setupNoteForm = function(form) { + var textarea; + new GLForm(form); + textarea = form.find(".js-note-text"); + return new Autosave(textarea, ["Note", form.find("#note_noteable_type").val(), form.find("#note_noteable_id").val(), form.find("#note_commit_id").val(), form.find("#note_type").val(), form.find("#note_line_code").val(), form.find("#note_position").val()]); + }; + + + /* + Called in response to the new note form being submitted + + Adds new note to list. + */ + + Notes.prototype.addNote = function(xhr, note, status) { + 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); + }; + + + /* + Called in response to the new note form being submitted + + Adds new note to list. + */ + + Notes.prototype.addDiscussionNote = function(xhr, note, status) { + this.renderDiscussionNote(note); + return this.removeDiscussionNoteForm($(xhr.target)); + }; + + + /* + Called in response to the edit note form being submitted + + Updates the current note field. + */ + + Notes.prototype.updateNote = function(_xhr, note, _status) { + var $html, $note_li; + $html = $(note.html); + gl.utils.localTimeAgo($('.js-timeago', $html)); + $html.syntaxHighlight(); + $html.find('.js-task-list-container').taskList('enable'); + $note_li = $('.note-row-' + note.id); + return $note_li.replaceWith($html); + }; + + + /* + Called in response to clicking the edit note link + + Replaces the note text with the note edit form + Adds a data attribute to the form with the original content of the note for cancellations + */ + + Notes.prototype.showEditForm = function(e, scrollTo, myLastNote) { + var $noteText, done, form, note; + e.preventDefault(); + note = $(this).closest(".note"); + note.addClass("is-editting"); + form = note.find(".note-edit-form"); + form.addClass('current-note-edit-form'); + note.find(".js-note-attachment-delete").show(); + done = function($noteText) { + var noteTextVal; + noteTextVal = $noteText.val(); + form.find('form.edit-note').data('original-note', noteTextVal); + return $noteText.val('').val(noteTextVal); + }; + new GLForm(form); + if ((scrollTo != null) && (myLastNote != null)) { + $('html, body').scrollTop($(document).height()); + return $('html, body').animate({ + scrollTop: myLastNote.offset().top - 150 + }, 500, function() { + var $noteText; + $noteText = form.find(".js-note-text"); + $noteText.focus(); + return done($noteText); + }); + } else { + $noteText = form.find('.js-note-text'); + $noteText.focus(); + return done($noteText); + } + }; + + + /* + Called in response to clicking the edit note link + + Hides edit form and restores the original note text to the editor textarea. + */ + + Notes.prototype.cancelEdit = function(e) { + var note; + e.preventDefault(); + note = $(e.target).closest('.note'); + return this.removeNoteEditForm(note); + }; + + Notes.prototype.removeNoteEditForm = function(note) { + var form; + form = note.find(".current-note-edit-form"); + note.removeClass("is-editting"); + form.removeClass("current-note-edit-form"); + return form.find(".js-note-text").val(form.find('form.edit-note').data('original-note')); + }; + + + /* + Called in response to deleting a note of any kind. + + Removes the actual note from view. + Removes the whole discussion if the last note is being removed. + */ + + Notes.prototype.removeNote = function(e) { + var noteId; + noteId = $(e.currentTarget).closest(".note").attr("id"); + $(".note[id='" + noteId + "']").each((function(_this) { + return function(i, el) { + var note, notes; + note = $(el); + notes = note.closest(".notes"); + if (notes.find(".note").length === 1) { + notes.closest(".timeline-entry").remove(); + notes.closest("tr").remove(); + } + return note.remove(); + }; + })(this)); + return this.updateNotesCount(-1); + }; + + + /* + Called in response to clicking the delete attachment link + + Removes the attachment wrapper view, including image tag if it exists + Resets the note editing form + */ + + 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(); + }; + + + /* + Called when clicking on the "reply" button for a diff line. + + Shows the note form below the notes. + */ + + Notes.prototype.replyToDiscussionNote = function(e) { + var form, replyLink; + form = this.formClone.clone(); + replyLink = $(e.target).closest(".js-discussion-reply-button"); + replyLink.hide(); + replyLink.after(form); + return this.setupDiscussionNoteForm(replyLink, form); + }; + + + /* + Shows the diff or discussion form and does some setup on it. + + Sets some hidden fields in the form. + + Note: dataHolder must have the "discussionId", "lineCode", "noteableType" + and "noteableId" data attributes set. + */ + + Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) { + form.attr('id', "new-discussion-note-form-" + (dataHolder.data("discussionId"))); + form.attr("data-line-code", dataHolder.data("lineCode")); + form.find("#note_type").val(dataHolder.data("noteType")); + form.find("#line_type").val(dataHolder.data("lineType")); + form.find("#note_commit_id").val(dataHolder.data("commitId")); + form.find("#note_line_code").val(dataHolder.data("lineCode")); + form.find("#note_position").val(dataHolder.attr("data-position")); + form.find("#note_noteable_type").val(dataHolder.data("noteableType")); + form.find("#note_noteable_id").val(dataHolder.data("noteableId")); + form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); + this.setupNoteForm(form); + form.find(".js-note-text").focus(); + return form.removeClass('js-main-target-form').addClass("discussion-form js-discussion-note-form"); + }; + + + /* + Called when clicking on the "add a comment" button on the side of a diff line. + + Inserts a temporary row for the form below the line. + Sets up the form and shows it. + */ + + Notes.prototype.addDiffNote = function(e) { + var $link, addForm, hasNotes, lineType, newForm, nextRow, noteForm, notesContent, replyButton, row, rowCssToAdd, targetContent; + e.preventDefault(); + $link = $(e.currentTarget); + row = $link.closest("tr"); + nextRow = row.next(); + hasNotes = nextRow.is(".notes_holder"); + addForm = false; + targetContent = ".notes_content"; + rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"></td></tr>"; + if (this.isParallelView()) { + lineType = $link.data("lineType"); + targetContent += "." + lineType; + rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\"></td><td class=\"notes_content parallel old\"></td><td class=\"notes_line\"></td><td class=\"notes_content parallel new\"></td></tr>"; + } + if (hasNotes) { + notesContent = nextRow.find(targetContent); + if (notesContent.length) { + replyButton = notesContent.find(".js-discussion-reply-button:visible"); + if (replyButton.length) { + e.target = replyButton[0]; + $.proxy(this.replyToDiscussionNote, replyButton[0], e).call(); + } else { + noteForm = notesContent.find(".js-discussion-note-form"); + if (noteForm.length === 0) { + addForm = true; + } + } + } + } else { + row.after(rowCssToAdd); + addForm = true; + } + if (addForm) { + newForm = this.formClone.clone(); + newForm.appendTo(row.next().find(targetContent)); + return this.setupDiscussionNoteForm($link, newForm); + } + }; + + + /* + Called in response to "cancel" on a diff note form. + + Shows the reply button again. + Removes the form and if necessary it's temporary row. + */ + + Notes.prototype.removeDiscussionNoteForm = function(form) { + var glForm, row; + row = form.closest("tr"); + glForm = form.data('gl-form'); + glForm.destroy(); + form.find(".js-note-text").data("autosave").reset(); + form.prev(".js-discussion-reply-button").show(); + if (row.is(".js-temp-notes-holder")) { + return row.remove(); + } else { + return form.remove(); + } + }; + + Notes.prototype.cancelDiscussionForm = function(e) { + var form; + e.preventDefault(); + form = $(e.target).closest(".js-discussion-note-form"); + return this.removeDiscussionNoteForm(form); + }; + + + /* + Called after an attachment file has been selected. + + Updates the file name for the selected attachment. + */ + + Notes.prototype.updateFormAttachment = function() { + var filename, form; + form = $(this).closest("form"); + filename = $(this).val().replace(/^.*[\\\/]/, ""); + return form.find(".js-attachment-filename").text(filename); + }; + + + /* + Called when the tab visibility changes + */ + + Notes.prototype.visibilityChange = function() { + 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); + form = textarea.parents('form'); + reopenbtn = form.find('.js-note-target-reopen'); + closebtn = form.find('.js-note-target-close'); + discardbtn = form.find('.js-note-discard'); + if (textarea.val().trim().length > 0) { + reopentext = reopenbtn.data('alternative-text'); + closetext = closebtn.data('alternative-text'); + if (reopenbtn.text() !== reopentext) { + reopenbtn.text(reopentext); + } + if (closebtn.text() !== closetext) { + closebtn.text(closetext); + } + if (reopenbtn.is(':not(.btn-comment-and-reopen)')) { + reopenbtn.addClass('btn-comment-and-reopen'); + } + if (closebtn.is(':not(.btn-comment-and-close)')) { + closebtn.addClass('btn-comment-and-close'); + } + if (discardbtn.is(':hidden')) { + return discardbtn.show(); + } + } else { + reopentext = reopenbtn.data('original-text'); + closetext = closebtn.data('original-text'); + if (reopenbtn.text() !== reopentext) { + reopenbtn.text(reopentext); + } + if (closebtn.text() !== closetext) { + closebtn.text(closetext); + } + if (reopenbtn.is('.btn-comment-and-reopen')) { + reopenbtn.removeClass('btn-comment-and-reopen'); + } + if (closebtn.is('.btn-comment-and-close')) { + closebtn.removeClass('btn-comment-and-close'); + } + if (discardbtn.is(':visible')) { + return discardbtn.hide(); + } + } + }; + + Notes.prototype.initTaskList = function() { + this.enableTaskList(); + return $(document).on('tasklist:changed', '.note .js-task-list-container', this.updateTaskList); + }; + + Notes.prototype.enableTaskList = function() { + return $('.note .js-task-list-container').taskList('enable'); + }; + + Notes.prototype.updateTaskList = function() { + return $('form', this).submit(); + }; + + Notes.prototype.updateNotesCount = function(updateCount) { + return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount); + }; + + return Notes; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee deleted file mode 100644 index d4de712f88c..00000000000 --- a/app/assets/javascripts/notes.js.coffee +++ /dev/null @@ -1,694 +0,0 @@ -#= require autosave -#= require autosize -#= require dropzone -#= require dropzone_input -#= require gfm_auto_complete -#= require jquery.atwho -#= require task_list - -class @Notes - @interval: null - - constructor: (notes_url, note_ids, last_fetched_at, view) -> - @notes_url = notes_url - @note_ids = note_ids - @last_fetched_at = last_fetched_at - @view = view - @noteable_url = document.URL - @notesCountBadge ||= $(".issuable-details").find(".notes-tab .badge") - @basePollingInterval = 15000 - @maxPollingSteps = 4 - - @cleanBinding() - @addBinding() - @setPollingInterval() - @setupMainTargetNoteForm() - @initTaskList() - - addBinding: -> - # add note to UI after creation - $(document).on "ajax:success", ".js-main-target-form", @addNote - $(document).on "ajax:success", ".js-discussion-note-form", @addDiscussionNote - - # catch note ajax errors - $(document).on "ajax:error", ".js-main-target-form", @addNoteError - - # change note in UI after update - $(document).on "ajax:success", "form.edit-note", @updateNote - - # Edit note link - $(document).on "click", ".js-note-edit", @showEditForm - $(document).on "click", ".note-edit-cancel", @cancelEdit - - # Reopen and close actions for Issue/MR combined with note form submit - $(document).on "click", ".js-comment-button", @updateCloseButton - $(document).on "keyup input", ".js-note-text", @updateTargetButtons - - # remove a note (in general) - $(document).on "click", ".js-note-delete", @removeNote - - # delete note attachment - $(document).on "click", ".js-note-attachment-delete", @removeAttachment - - # reset main target form after submit - $(document).on "ajax:complete", ".js-main-target-form", @reenableTargetFormSubmitButton - $(document).on "ajax:success", ".js-main-target-form", @resetMainTargetForm - - # reset main target form when clicking discard - $(document).on "click", ".js-note-discard", @resetMainTargetForm - - # update the file name when an attachment is selected - $(document).on "change", ".js-note-attachment-input", @updateFormAttachment - - # reply to diff/discussion notes - $(document).on "click", ".js-discussion-reply-button", @replyToDiscussionNote - - # add diff note - $(document).on "click", ".js-add-diff-note-button", @addDiffNote - - # hide diff note form - $(document).on "click", ".js-close-discussion-note-form", @cancelDiscussionForm - - # fetch notes when tab becomes visible - $(document).on "visibilitychange", @visibilityChange - - # when issue status changes, we need to refresh data - $(document).on "issuable:change", @refresh - - # when a key is clicked on the notes - $(document).on "keydown", ".js-note-text", @keydownNoteText - - cleanBinding: -> - $(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 "click", ".js-note-target-reopen" - $(document).off "click", ".js-note-target-close" - $(document).off "click", ".js-note-discard" - $(document).off "keydown", ".js-note-text" - - $('.note .js-task-list-container').taskList('disable') - $(document).off 'tasklist:changed', '.note .js-task-list-container' - - keydownNoteText: (e) => - return if isMetaKey e - - $textarea = $(e.target) - - # Edit previous note when UP arrow is hit - switch e.which - when 38 - return unless $textarea.val() is '' - - myLastNote = $("li.note[data-author-id='#{gon.current_user_id}'][data-editable]:last") - if myLastNote.length - myLastNoteEditBtn = myLastNote.find('.js-note-edit') - myLastNoteEditBtn.trigger('click', [true, myLastNote]) - - # Cancel creating diff note or editing any note when ESCAPE is hit - when 27 - discussionNoteForm = $textarea.closest('.js-discussion-note-form') - if discussionNoteForm.length - if $textarea.val() isnt '' - return unless confirm('Are you sure you want to cancel creating this comment?') - - @removeDiscussionNoteForm(discussionNoteForm) - return - - editNote = $textarea.closest('.note') - if editNote.length - originalText = $textarea.closest('form').data('original-note') - newText = $textarea.val() - if originalText isnt newText - return unless confirm('Are you sure you want to cancel editing this comment?') - - @removeNoteEditForm(editNote) - - - isMetaKey = (e) -> - (e.metaKey or e.ctrlKey or e.altKey or e.shiftKey) - - initRefresh: -> - clearInterval(Notes.interval) - Notes.interval = setInterval => - @refresh() - , @pollingInterval - - refresh: => - if not document.hidden and document.URL.indexOf(@noteable_url) is 0 - @getContent() - - getContent: -> - return if @refreshing - - @refreshing = true - - $.ajax - url: @notes_url - data: "last_fetched_at=" + @last_fetched_at - dataType: "json" - success: (data) => - notes = data.notes - @last_fetched_at = data.last_fetched_at - @setPollingInterval(data.notes.length) - $.each notes, (i, note) => - if note.discussion_html? - @renderDiscussionNote(note) - else - @renderNote(note) - .always () => - @refreshing = false - - ### - Increase @pollingInterval up to 120 seconds on every function call, - if `shouldReset` has a truthy value, 'null' or 'undefined' the variable - will reset to @basePollingInterval. - - Note: this function is used to gradually increase the polling interval - if there aren't new notes coming from the server - ### - setPollingInterval: (shouldReset = true) -> - nthInterval = @basePollingInterval * Math.pow(2, @maxPollingSteps - 1) - if shouldReset - @pollingInterval = @basePollingInterval - else if @pollingInterval < nthInterval - @pollingInterval *= 2 - - @initRefresh() - - ### - Render note in main comments area. - - Note: for rendering inline notes use renderDiscussionNote - ### - renderNote: (note) -> - unless note.valid - if note.award - new Flash('You have already awarded this emoji!', 'alert') - return - - if note.award - votesBlock = $('.js-awards-block').eq 0 - gl.awardsHandler.addAwardToEmojiBar votesBlock, note.name - gl.awardsHandler.scrollToAwards() - - # render note if it not present in loaded list - # or skip if rendered - else if @isNewNote(note) - @note_ids.push(note.id) - - $notesList = $('ul.main-notes-list') - - $notesList - .append(note.html) - .syntaxHighlight() - - # Update datetime format on the recent note - gl.utils.localTimeAgo($notesList.find("#note_#{note.id} .js-timeago"), false) - - @initTaskList() - @updateNotesCount(1) - - - ### - Check if note does not exists on page - ### - isNewNote: (note) -> - $.inArray(note.id, @note_ids) == -1 - - isParallelView: -> - @view == 'parallel' - - ### - Render note in discussion area. - - Note: for rendering inline notes use renderDiscussionNote - ### - renderDiscussionNote: (note) -> - return unless @isNewNote(note) - - @note_ids.push(note.id) - form = $("#new-discussion-note-form-#{note.discussion_id}") - if note.original_discussion_id? and form.length is 0 - form = $("#new-discussion-note-form-#{note.original_discussion_id}") - row = form.closest("tr") - note_html = $(note.html) - note_html.syntaxHighlight() - - # is this the first note of discussion? - discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']") - if note.original_discussion_id? and discussionContainer.length is 0 - discussionContainer = $(".notes[data-discussion-id='" + note.original_discussion_id + "']") - if discussionContainer.length is 0 - # insert the note and the reply button after the temp row - row.after note.diff_discussion_html - - # remove the note (will be added again below) - row.next().find(".note").remove() - - # Before that, the container didn't exist - discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']") - - # Add note to 'Changes' page discussions - discussionContainer.append note_html - - # Init discussion on 'Discussion' page if it is merge request page - if $('body').attr('data-page').indexOf('projects:merge_request') is 0 - $('ul.main-notes-list') - .append(note.discussion_html) - .syntaxHighlight() - else - # append new note to all matching discussions - discussionContainer.append note_html - - gl.utils.localTimeAgo($('.js-timeago', note_html), false) - - @updateNotesCount(1) - - ### - Called in response the main target form has been successfully submitted. - - Removes any errors. - Resets text and preview. - Resets buttons. - ### - resetMainTargetForm: (e) => - form = $(".js-main-target-form") - - # remove validation errors - form.find(".js-errors").remove() - - # reset text and preview - form.find(".js-md-write-button").click() - form.find(".js-note-text").val("").trigger "input" - - form.find(".js-note-text").data("autosave").reset() - - @updateTargetButtons(e) - - reenableTargetFormSubmitButton: -> - form = $(".js-main-target-form") - - form.find(".js-note-text").trigger "input" - - ### - Shows the main form and does some setup on it. - - Sets some hidden fields in the form. - ### - setupMainTargetNoteForm: -> - # find the form - form = $(".js-new-note-form") - - # Set a global clone of the form for later cloning - @formClone = form.clone() - - # show the form - @setupNoteForm(form) - - # fix classes - form.removeClass "js-new-note-form" - form.addClass "js-main-target-form" - - form.find("#note_line_code").remove() - form.find("#note_position").remove() - form.find("#note_type").remove() - - @parentTimeline = form.parents('.timeline') - - ### - General note form setup. - - deactivates the submit button when text is empty - hides the preview button when text is empty - setup GFM auto complete - show the form - ### - setupNoteForm: (form) -> - new GLForm form - - textarea = form.find(".js-note-text") - - new Autosave textarea, [ - "Note" - form.find("#note_noteable_type").val() - form.find("#note_noteable_id").val() - form.find("#note_commit_id").val() - form.find("#note_type").val() - form.find("#note_line_code").val() - form.find("#note_position").val() - ] - - ### - Called in response to the new note form being submitted - - Adds new note to list. - ### - addNote: (xhr, note, status) => - @renderNote(note) - - addNoteError: (xhr, note, status) => - new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', @parentTimeline) - - ### - Called in response to the new note form being submitted - - Adds new note to list. - ### - addDiscussionNote: (xhr, note, status) => - @renderDiscussionNote(note) - - # cleanup after successfully creating a diff/discussion note - @removeDiscussionNoteForm($(xhr.target)) - - ### - Called in response to the edit note form being submitted - - Updates the current note field. - ### - updateNote: (_xhr, note, _status) => - # Convert returned HTML to a jQuery object so we can modify it further - $html = $(note.html) - - gl.utils.localTimeAgo($('.js-timeago', $html)) - - $html.syntaxHighlight() - $html.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.replaceWith($html) - - ### - Called in response to clicking the edit note link - - Replaces the note text with the note edit form - Adds a data attribute to the form with the original content of the note for cancellations - ### - showEditForm: (e, scrollTo, myLastNote) -> - e.preventDefault() - note = $(this).closest(".note") - note.addClass "is-editting" - form = note.find(".note-edit-form") - - form.addClass('current-note-edit-form') - - # Show the attachment delete link - note.find(".js-note-attachment-delete").show() - - done = ($noteText) -> - # Neat little trick to put the cursor at the end - noteTextVal = $noteText.val() - # Store the original note text in a data attribute to retrieve if a user cancels edit. - form.find('form.edit-note').data 'original-note', noteTextVal - $noteText.val('').val(noteTextVal); - - new GLForm form - if scrollTo? and myLastNote? - # scroll to the bottom - # so the open of the last element doesn't make a jump - $('html, body').scrollTop($(document).height()); - $('html, body').animate({ - scrollTop: myLastNote.offset().top - 150 - }, 500, -> - $noteText = form.find(".js-note-text") - $noteText.focus() - done($noteText) - ); - else - $noteText = form.find('.js-note-text') - $noteText.focus() - done($noteText) - - ### - Called in response to clicking the edit note link - - Hides edit form and restores the original note text to the editor textarea. - ### - cancelEdit: (e) => - e.preventDefault() - note = $(e.target).closest('.note') - @removeNoteEditForm(note) - - removeNoteEditForm: (note) -> - form = note.find(".current-note-edit-form") - note.removeClass "is-editting" - form.removeClass("current-note-edit-form") - # Replace markdown textarea text with original note text. - form.find(".js-note-text").val(form.find('form.edit-note').data('original-note')) - - ### - Called in response to deleting a note of any kind. - - Removes the actual note from view. - Removes the whole discussion if the last note is being removed. - ### - removeNote: (e) => - noteId = $(e.currentTarget) - .closest(".note") - .attr("id") - - # A same note appears in the "Discussion" and in the "Changes" tab, we have - # to remove all. Using $(".note[id='noteId']") ensure we get all the notes, - # where $("#noteId") would return only one. - $(".note[id='#{noteId}']").each (i, el) => - note = $(el) - notes = note.closest(".notes") - - # check if this is the last note for this line - if notes.find(".note").length is 1 - - # "Discussions" tab - notes.closest(".timeline-entry").remove() - - # "Changes" tab / commit view - notes.closest("tr").remove() - - note.remove() - - # Decrement the "Discussions" counter only once - @updateNotesCount(-1) - - ### - Called in response to clicking the delete attachment link - - Removes the attachment wrapper view, including image tag if it exists - Resets the note editing form - ### - removeAttachment: -> - note = $(this).closest(".note") - note.find(".note-attachment").remove() - note.find(".note-body > .note-text").show() - note.find(".note-header").show() - note.find(".current-note-edit-form").remove() - - ### - Called when clicking on the "reply" button for a diff line. - - Shows the note form below the notes. - ### - replyToDiscussionNote: (e) => - form = @formClone.clone() - replyLink = $(e.target).closest(".js-discussion-reply-button") - replyLink.hide() - - # insert the form after the button - replyLink.after form - - # show the form - @setupDiscussionNoteForm(replyLink, form) - - ### - Shows the diff or discussion form and does some setup on it. - - Sets some hidden fields in the form. - - Note: dataHolder must have the "discussionId", "lineCode", "noteableType" - and "noteableId" data attributes set. - ### - setupDiscussionNoteForm: (dataHolder, form) => - # setup note target - form.attr 'id', "new-discussion-note-form-#{dataHolder.data("discussionId")}" - form.attr "data-line-code", dataHolder.data("lineCode") - form.find("#note_type").val dataHolder.data("noteType") - form.find("#line_type").val dataHolder.data("lineType") - form.find("#note_commit_id").val dataHolder.data("commitId") - form.find("#note_line_code").val dataHolder.data("lineCode") - form.find("#note_position").val dataHolder.attr("data-position") - form.find("#note_noteable_type").val dataHolder.data("noteableType") - form.find("#note_noteable_id").val dataHolder.data("noteableId") - form.find('.js-note-discard') - .show() - .removeClass('js-note-discard') - .addClass('js-close-discussion-note-form') - .text(form.find('.js-close-discussion-note-form').data('cancel-text')) - @setupNoteForm form - form.find(".js-note-text").focus() - form - .removeClass('js-main-target-form') - .addClass("discussion-form js-discussion-note-form") - - ### - Called when clicking on the "add a comment" button on the side of a diff line. - - Inserts a temporary row for the form below the line. - Sets up the form and shows it. - ### - addDiffNote: (e) => - e.preventDefault() - $link = $(e.currentTarget) - row = $link.closest("tr") - nextRow = row.next() - hasNotes = nextRow.is(".notes_holder") - addForm = false - targetContent = ".notes_content" - rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"></td></tr>" - - # In parallel view, look inside the correct left/right pane - if @isParallelView() - lineType = $link.data("lineType") - targetContent += "." + lineType - rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\"></td><td class=\"notes_content parallel old\"></td><td class=\"notes_line\"></td><td class=\"notes_content parallel new\"></td></tr>" - - if hasNotes - notesContent = nextRow.find(targetContent) - if notesContent.length - replyButton = notesContent.find(".js-discussion-reply-button:visible") - if replyButton.length - e.target = replyButton[0] - $.proxy(@replyToDiscussionNote, replyButton[0], e).call() - else - # In parallel view, the form may not be present in one of the panes - noteForm = notesContent.find(".js-discussion-note-form") - if noteForm.length == 0 - addForm = true - else - # add a notes row and insert the form - row.after rowCssToAdd - addForm = true - - if addForm - newForm = @formClone.clone() - newForm.appendTo row.next().find(targetContent) - - # show the form - @setupDiscussionNoteForm $link, newForm - - ### - Called in response to "cancel" on a diff note form. - - Shows the reply button again. - Removes the form and if necessary it's temporary row. - ### - removeDiscussionNoteForm: (form)-> - row = form.closest("tr") - - glForm = form.data 'gl-form' - glForm.destroy() - - form.find(".js-note-text").data("autosave").reset() - - # show the reply button (will only work for replies) - form.prev(".js-discussion-reply-button").show() - if row.is(".js-temp-notes-holder") - # remove temporary row for diff lines - row.remove() - else - # only remove the form - form.remove() - - cancelDiscussionForm: (e) => - e.preventDefault() - form = $(e.target).closest(".js-discussion-note-form") - @removeDiscussionNoteForm(form) - - ### - Called after an attachment file has been selected. - - Updates the file name for the selected attachment. - ### - updateFormAttachment: -> - form = $(this).closest("form") - - # get only the basename - filename = $(this).val().replace(/^.*[\\\/]/, "") - form.find(".js-attachment-filename").text filename - - ### - Called when the tab visibility changes - ### - visibilityChange: => - @refresh() - - updateCloseButton: (e) => - textarea = $(e.target) - form = textarea.parents('form') - closebtn = form.find('.js-note-target-close') - closebtn.text(closebtn.data('original-text')) - - updateTargetButtons: (e) => - textarea = $(e.target) - form = textarea.parents('form') - reopenbtn = form.find('.js-note-target-reopen') - closebtn = form.find('.js-note-target-close') - discardbtn = form.find('.js-note-discard') - - if textarea.val().trim().length > 0 - reopentext = reopenbtn.data('alternative-text') - closetext = closebtn.data('alternative-text') - - if reopenbtn.text() isnt reopentext - reopenbtn.text(reopentext) - - if closebtn.text() isnt closetext - closebtn.text(closetext) - - if reopenbtn.is(':not(.btn-comment-and-reopen)') - reopenbtn.addClass('btn-comment-and-reopen') - - if closebtn.is(':not(.btn-comment-and-close)') - closebtn.addClass('btn-comment-and-close') - - if discardbtn.is(':hidden') - discardbtn.show() - else - reopentext = reopenbtn.data('original-text') - closetext = closebtn.data('original-text') - - if reopenbtn.text() isnt reopentext - reopenbtn.text(reopentext) - - if closebtn.text() isnt closetext - closebtn.text(closetext) - - if reopenbtn.is('.btn-comment-and-reopen') - reopenbtn.removeClass('btn-comment-and-reopen') - - if closebtn.is('.btn-comment-and-close') - closebtn.removeClass('btn-comment-and-close') - - if discardbtn.is(':visible') - discardbtn.hide() - - initTaskList: -> - @enableTaskList() - $(document).on 'tasklist:changed', '.note .js-task-list-container', @updateTaskList - - enableTaskList: -> - $('.note .js-task-list-container').taskList('enable') - - updateTaskList: -> - $('form', this).submit() - - updateNotesCount: (updateCount) -> - @notesCountBadge.text(parseInt(@notesCountBadge.text()) + updateCount) diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js new file mode 100644 index 00000000000..a41e9d3fabe --- /dev/null +++ b/app/assets/javascripts/notifications_dropdown.js @@ -0,0 +1,30 @@ +(function() { + this.NotificationsDropdown = (function() { + function NotificationsDropdown() { + $(document).off('click', '.update-notification').on('click', '.update-notification', function(e) { + var form, label, notificationLevel; + e.preventDefault(); + if ($(this).is('.is-active') && $(this).data('notification-level') === 'custom') { + return; + } + notificationLevel = $(this).data('notification-level'); + label = $(this).data('notification-title'); + form = $(this).parents('.notification-form:first'); + form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner'); + form.find('#notification_setting_level').val(notificationLevel); + return form.submit(); + }); + $(document).off('ajax:success', '.notification-form').on('ajax:success', '.notification-form', function(e, data) { + if (data.saved) { + return $(e.currentTarget).closest('.notification-dropdown').replaceWith(data.html); + } else { + return new Flash('Failed to save new settings', 'alert'); + } + }); + } + + return NotificationsDropdown; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/notifications_dropdown.js.coffee b/app/assets/javascripts/notifications_dropdown.js.coffee deleted file mode 100644 index 0bbd082c156..00000000000 --- a/app/assets/javascripts/notifications_dropdown.js.coffee +++ /dev/null @@ -1,25 +0,0 @@ -class @NotificationsDropdown - constructor: -> - $(document) - .off 'click', '.update-notification' - .on 'click', '.update-notification', (e) -> - e.preventDefault() - - return if $(this).is('.is-active') and $(this).data('notification-level') is 'custom' - - notificationLevel = $(@).data 'notification-level' - label = $(@).data 'notification-title' - form = $(this).parents('.notification-form:first') - form.find('.js-notification-loading').toggleClass 'fa-bell fa-spin fa-spinner' - form.find('#notification_setting_level').val(notificationLevel) - form.submit() - - $(document) - .off 'ajax:success', '.notification-form' - .on 'ajax:success', '.notification-form', (e, data) -> - if data.saved - $(e.currentTarget) - .closest('.notification-dropdown') - .replaceWith(data.html) - else - new Flash('Failed to save new settings', 'alert') diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js new file mode 100644 index 00000000000..6b2ef17ef6b --- /dev/null +++ b/app/assets/javascripts/notifications_form.js @@ -0,0 +1,58 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.NotificationsForm = (function() { + function NotificationsForm() { + this.toggleCheckbox = bind(this.toggleCheckbox, this); + this.removeEventListeners(); + this.initEventListeners(); + } + + NotificationsForm.prototype.removeEventListeners = function() { + return $(document).off('change', '.js-custom-notification-event'); + }; + + NotificationsForm.prototype.initEventListeners = function() { + return $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox); + }; + + NotificationsForm.prototype.toggleCheckbox = function(e) { + var $checkbox, $parent; + $checkbox = $(e.currentTarget); + $parent = $checkbox.closest('.checkbox'); + return this.saveEvent($checkbox, $parent); + }; + + NotificationsForm.prototype.showCheckboxLoadingSpinner = function($parent) { + return $parent.addClass('is-loading').find('.custom-notification-event-loading').removeClass('fa-check').addClass('fa-spin fa-spinner').removeClass('is-done'); + }; + + NotificationsForm.prototype.saveEvent = function($checkbox, $parent) { + var form; + form = $parent.parents('form:first'); + return $.ajax({ + url: form.attr('action'), + method: form.attr('method'), + dataType: 'json', + data: form.serialize(), + beforeSend: (function(_this) { + return function() { + return _this.showCheckboxLoadingSpinner($parent); + }; + })(this) + }).done(function(data) { + $checkbox.enable(); + if (data.saved) { + $parent.find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done'); + return setTimeout(function() { + return $parent.removeClass('is-loading').find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done'); + }, 2000); + } + }); + }; + + return NotificationsForm; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/notifications_form.js.coffee b/app/assets/javascripts/notifications_form.js.coffee deleted file mode 100644 index 3432428702a..00000000000 --- a/app/assets/javascripts/notifications_form.js.coffee +++ /dev/null @@ -1,49 +0,0 @@ -class @NotificationsForm - constructor: -> - @removeEventListeners() - @initEventListeners() - - removeEventListeners: -> - $(document).off 'change', '.js-custom-notification-event' - - initEventListeners: -> - $(document).on 'change', '.js-custom-notification-event', @toggleCheckbox - - toggleCheckbox: (e) => - $checkbox = $(e.currentTarget) - $parent = $checkbox.closest('.checkbox') - @saveEvent($checkbox, $parent) - - showCheckboxLoadingSpinner: ($parent) -> - $parent - .addClass 'is-loading' - .find '.custom-notification-event-loading' - .removeClass 'fa-check' - .addClass 'fa-spin fa-spinner' - .removeClass 'is-done' - - saveEvent: ($checkbox, $parent) -> - form = $parent.parents('form:first') - - $.ajax( - url: form.attr('action') - method: form.attr('method') - dataType: 'json' - data: form.serialize() - - beforeSend: => - @showCheckboxLoadingSpinner($parent) - ).done (data) -> - $checkbox.enable() - - if data.saved - $parent - .find '.custom-notification-event-loading' - .toggleClass 'fa-spin fa-spinner fa-check is-done' - - setTimeout(-> - $parent - .removeClass 'is-loading' - .find '.custom-notification-event-loading' - .toggleClass 'fa-spin fa-spinner fa-check is-done' - , 2000) diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js new file mode 100644 index 00000000000..b81ed50cb48 --- /dev/null +++ b/app/assets/javascripts/pager.js @@ -0,0 +1,63 @@ +(function() { + this.Pager = { + init: function(limit, preload, disable, callback) { + this.limit = limit != null ? limit : 0; + this.disable = disable != null ? disable : false; + this.callback = callback != null ? callback : $.noop; + this.loading = $('.loading').first(); + if (preload) { + this.offset = 0; + this.getOld(); + } else { + this.offset = this.limit; + } + return this.initLoadMore(); + }, + getOld: function() { + this.loading.show(); + return $.ajax({ + type: "GET", + url: $(".content_list").data('href') || location.href, + data: "limit=" + this.limit + "&offset=" + this.offset, + complete: (function(_this) { + return function() { + return _this.loading.hide(); + }; + })(this), + success: function(data) { + Pager.append(data.count, data.html); + return Pager.callback(); + }, + dataType: "json" + }); + }, + append: function(count, html) { + $(".content_list").append(html); + if (count > 0) { + return this.offset += count; + } else { + return this.disable = true; + } + }, + initLoadMore: function() { + $(document).unbind('scroll'); + return $(document).endlessScroll({ + bottomPixels: 400, + fireDelay: 1000, + fireOnce: true, + ceaseFire: function() { + return Pager.disable; + }, + callback: (function(_this) { + return function(i) { + if (!_this.loading.is(':visible')) { + _this.loading.show(); + return Pager.getOld(); + } + }; + })(this) + }); + } + }; + +}).call(this); diff --git a/app/assets/javascripts/pager.js.coffee b/app/assets/javascripts/pager.js.coffee deleted file mode 100644 index 8049c5c30e2..00000000000 --- a/app/assets/javascripts/pager.js.coffee +++ /dev/null @@ -1,44 +0,0 @@ -@Pager = - init: (@limit = 0, preload, @disable = false, @callback = $.noop) -> - @loading = $('.loading').first() - - if preload - @offset = 0 - @getOld() - else - @offset = @limit - @initLoadMore() - - getOld: -> - @loading.show() - $.ajax - type: "GET" - url: $(".content_list").data('href') || location.href - data: "limit=" + @limit + "&offset=" + @offset - complete: => - @loading.hide() - success: (data) -> - Pager.append(data.count, data.html) - Pager.callback() - dataType: "json" - - append: (count, html) -> - $(".content_list").append html - if count > 0 - @offset += count - else - @disable = true - - initLoadMore: -> - $(document).unbind('scroll') - $(document).endlessScroll - bottomPixels: 400 - fireDelay: 1000 - fireOnce: true - ceaseFire: -> - Pager.disable - - callback: (i) => - unless @loading.is(':visible') - @loading.show() - Pager.getOld() diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js new file mode 100644 index 00000000000..a3eea316f67 --- /dev/null +++ b/app/assets/javascripts/profile/gl_crop.js @@ -0,0 +1,169 @@ +(function() { + var GitLabCrop, + bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + GitLabCrop = (function() { + var FILENAMEREGEX; + + FILENAMEREGEX = /^.*[\\\/]/; + + function GitLabCrop(input, opts) { + var ref, ref1, ref2, ref3, ref4; + if (opts == null) { + opts = {}; + } + this.onUploadImageBtnClick = bind(this.onUploadImageBtnClick, this); + this.onModalHide = bind(this.onModalHide, this); + this.onModalShow = bind(this.onModalShow, this); + this.onPickImageClick = bind(this.onPickImageClick, this); + this.fileInput = $(input); + this.fileInput.attr('name', (this.fileInput.attr('name')) + "-trigger").attr('id', (this.fileInput.attr('id')) + "-trigger"); + this.exportWidth = (ref = opts.exportWidth) != null ? ref : 200, this.exportHeight = (ref1 = opts.exportHeight) != null ? ref1 : 200, this.cropBoxWidth = (ref2 = opts.cropBoxWidth) != null ? ref2 : 200, this.cropBoxHeight = (ref3 = opts.cropBoxHeight) != null ? ref3 : 200, this.form = (ref4 = opts.form) != null ? ref4 : this.fileInput.parents('form'), this.filename = opts.filename, this.previewImage = opts.previewImage, this.modalCrop = opts.modalCrop, this.pickImageEl = opts.pickImageEl, this.uploadImageBtn = opts.uploadImageBtn, this.modalCropImg = opts.modalCropImg; + this.filename = this.getElement(this.filename); + this.previewImage = this.getElement(this.previewImage); + this.pickImageEl = this.getElement(this.pickImageEl); + this.modalCrop = _.isString(this.modalCrop) ? $(this.modalCrop) : this.modalCrop; + this.uploadImageBtn = _.isString(this.uploadImageBtn) ? $(this.uploadImageBtn) : this.uploadImageBtn; + this.modalCropImg = _.isString(this.modalCropImg) ? $(this.modalCropImg) : this.modalCropImg; + this.cropActionsBtn = this.modalCrop.find('[data-method]'); + this.bindEvents(); + } + + GitLabCrop.prototype.getElement = function(selector) { + return $(selector, this.form); + }; + + GitLabCrop.prototype.bindEvents = function() { + var _this; + _this = this; + this.fileInput.on('change', function(e) { + return _this.onFileInputChange(e, this); + }); + this.pickImageEl.on('click', this.onPickImageClick); + this.modalCrop.on('shown.bs.modal', this.onModalShow); + this.modalCrop.on('hidden.bs.modal', this.onModalHide); + this.uploadImageBtn.on('click', this.onUploadImageBtnClick); + this.cropActionsBtn.on('click', function(e) { + var btn; + btn = this; + return _this.onActionBtnClick(btn); + }); + return this.croppedImageBlob = null; + }; + + GitLabCrop.prototype.onPickImageClick = function() { + return this.fileInput.trigger('click'); + }; + + GitLabCrop.prototype.onModalShow = function() { + var _this; + _this = this; + return this.modalCropImg.cropper({ + viewMode: 1, + center: false, + aspectRatio: 1, + modal: true, + scalable: false, + rotatable: false, + zoomable: true, + dragMode: 'move', + guides: false, + zoomOnTouch: false, + zoomOnWheel: false, + cropBoxMovable: false, + cropBoxResizable: false, + toggleDragModeOnDblclick: false, + built: function() { + var $image, container, cropBoxHeight, cropBoxWidth; + $image = $(this); + container = $image.cropper('getContainerData'); + cropBoxWidth = _this.cropBoxWidth; + cropBoxHeight = _this.cropBoxHeight; + return $image.cropper('setCropBoxData', { + width: cropBoxWidth, + height: cropBoxHeight, + left: (container.width - cropBoxWidth) / 2, + top: (container.height - cropBoxHeight) / 2 + }); + } + }); + }; + + GitLabCrop.prototype.onModalHide = function() { + return this.modalCropImg.attr('src', '').cropper('destroy'); + }; + + GitLabCrop.prototype.onUploadImageBtnClick = function(e) { + e.preventDefault(); + this.setBlob(); + this.setPreview(); + this.modalCrop.modal('hide'); + return this.fileInput.val(''); + }; + + GitLabCrop.prototype.onActionBtnClick = function(btn) { + var data, result; + data = $(btn).data(); + if (this.modalCropImg.data('cropper') && data.method) { + return result = this.modalCropImg.cropper(data.method, data.option); + } + }; + + GitLabCrop.prototype.onFileInputChange = function(e, input) { + return this.readFile(input); + }; + + GitLabCrop.prototype.readFile = function(input) { + var _this, reader; + _this = this; + reader = new FileReader; + reader.onload = function() { + _this.modalCropImg.attr('src', reader.result); + return _this.modalCrop.modal('show'); + }; + return reader.readAsDataURL(input.files[0]); + }; + + GitLabCrop.prototype.dataURLtoBlob = function(dataURL) { + var array, binary, i, k, len, v; + binary = atob(dataURL.split(',')[1]); + array = []; + for (k = i = 0, len = binary.length; i < len; k = ++i) { + v = binary[k]; + array.push(binary.charCodeAt(k)); + } + return new Blob([new Uint8Array(array)], { + type: 'image/png' + }); + }; + + GitLabCrop.prototype.setPreview = function() { + var filename; + this.previewImage.attr('src', this.dataURL); + filename = this.fileInput.val().replace(FILENAMEREGEX, ''); + return this.filename.text(filename); + }; + + GitLabCrop.prototype.setBlob = function() { + this.dataURL = this.modalCropImg.cropper('getCroppedCanvas', { + width: 200, + height: 200 + }).toDataURL('image/png'); + return this.croppedImageBlob = this.dataURLtoBlob(this.dataURL); + }; + + GitLabCrop.prototype.getBlob = function() { + return this.croppedImageBlob; + }; + + return GitLabCrop; + + })(); + + $.fn.glCrop = function(opts) { + return this.each(function() { + return $(this).data('glcrop', new GitLabCrop(this, opts)); + }); + }; + +}).call(this); diff --git a/app/assets/javascripts/profile/gl_crop.js.coffee b/app/assets/javascripts/profile/gl_crop.js.coffee deleted file mode 100644 index df9bfdfa6cc..00000000000 --- a/app/assets/javascripts/profile/gl_crop.js.coffee +++ /dev/null @@ -1,152 +0,0 @@ -class GitLabCrop - # Matches everything but the file name - FILENAMEREGEX = /^.*[\\\/]/ - - constructor: (input, opts = {}) -> - @fileInput = $(input) - - # We should rename to avoid spec to fail - # Form will submit the proper input filed with a file using FormData - @fileInput - .attr('name', "#{@fileInput.attr('name')}-trigger") - .attr('id', "#{@fileInput.attr('id')}-trigger") - - # Set defaults - { - @exportWidth = 200 - @exportHeight = 200 - @cropBoxWidth = 200 - @cropBoxHeight = 200 - @form = @fileInput.parents('form') - - # Required params - @filename - @previewImage - @modalCrop - @pickImageEl - @uploadImageBtn - @modalCropImg - } = opts - - # Ensure needed elements are jquery objects - # If selector is provided we will convert them to a jQuery Object - @filename = @getElement(@filename) - @previewImage = @getElement(@previewImage) - @pickImageEl = @getElement(@pickImageEl) - - # Modal elements usually are outside the @form element - @modalCrop = if _.isString(@modalCrop) then $(@modalCrop) else @modalCrop - @uploadImageBtn = if _.isString(@uploadImageBtn) then $(@uploadImageBtn) else @uploadImageBtn - @modalCropImg = if _.isString(@modalCropImg) then $(@modalCropImg) else @modalCropImg - - @cropActionsBtn = @modalCrop.find('[data-method]') - - @bindEvents() - - getElement: (selector) -> - $(selector, @form) - - bindEvents: -> - _this = @ - @fileInput.on 'change', (e) -> - _this.onFileInputChange(e, @) - - @pickImageEl.on 'click', @onPickImageClick - @modalCrop.on 'shown.bs.modal', @onModalShow - @modalCrop.on 'hidden.bs.modal', @onModalHide - @uploadImageBtn.on 'click', @onUploadImageBtnClick - @cropActionsBtn.on 'click', (e) -> - btn = @ - _this.onActionBtnClick(btn) - @croppedImageBlob = null - - onPickImageClick: => - @fileInput.trigger('click') - - onModalShow: => - _this = @ - @modalCropImg.cropper( - viewMode: 1 - center: false - aspectRatio: 1 - modal: true - scalable: false - rotatable: false - zoomable: true - dragMode: 'move' - guides: false - zoomOnTouch: false - zoomOnWheel: false - cropBoxMovable: false - cropBoxResizable: false - toggleDragModeOnDblclick: false - built: -> - $image = $(@) - container = $image.cropper 'getContainerData' - cropBoxWidth = _this.cropBoxWidth; - cropBoxHeight = _this.cropBoxHeight; - - $image.cropper('setCropBoxData', - width: cropBoxWidth, - height: cropBoxHeight, - left: (container.width - cropBoxWidth) / 2, - top: (container.height - cropBoxHeight) / 2 - ) - ) - - - onModalHide: => - @modalCropImg - .attr('src', '') # Remove attached image - .cropper('destroy') # Destroy cropper instance - - onUploadImageBtnClick: (e) => - e.preventDefault() - @setBlob() - @setPreview() - @modalCrop.modal('hide') - @fileInput.val('') - - onActionBtnClick: (btn) -> - data = $(btn).data() - - if @modalCropImg.data('cropper') && data.method - result = @modalCropImg.cropper data.method, data.option - - onFileInputChange: (e, input) -> - @readFile(input) - - readFile: (input) -> - _this = @ - reader = new FileReader - reader.onload = -> - _this.modalCropImg.attr('src', reader.result) - _this.modalCrop.modal('show') - - reader.readAsDataURL(input.files[0]) - - dataURLtoBlob: (dataURL) -> - binary = atob(dataURL.split(',')[1]) - array = [] - for v, k in binary - array.push(binary.charCodeAt(k)) - new Blob([new Uint8Array(array)], type: 'image/png') - - setPreview: -> - @previewImage.attr('src', @dataURL) - filename = @fileInput.val().replace(FILENAMEREGEX, '') - @filename.text(filename) - - setBlob: -> - @dataURL = @modalCropImg.cropper('getCroppedCanvas', - width: 200 - height: 200 - ).toDataURL('image/png') - @croppedImageBlob = @dataURLtoBlob(@dataURL) - - getBlob: -> - @croppedImageBlob - -$.fn.glCrop = (opts) -> - return @.each -> - $(@).data('glcrop', new GitLabCrop(@, opts)) diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js new file mode 100644 index 00000000000..ed1d87abafe --- /dev/null +++ b/app/assets/javascripts/profile/profile.js @@ -0,0 +1,102 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Profile = (function() { + function Profile(opts) { + var cropOpts, ref; + if (opts == null) { + opts = {}; + } + this.onSubmitForm = bind(this.onSubmitForm, this); + this.form = (ref = opts.form) != null ? ref : $('.edit-user'); + $('.js-preferences-form').on('change.preference', 'input[type=radio]', function() { + return $(this).parents('form').submit(); + }); + $('#user_notification_email').on('change', function() { + return $(this).parents('form').submit(); + }); + $('.update-username').on('ajax:before', function() { + $('.loading-username').show(); + $(this).find('.update-success').hide(); + return $(this).find('.update-failed').hide(); + }); + $('.update-username').on('ajax:complete', function() { + $('.loading-username').hide(); + $(this).find('.btn-save').enable(); + return $(this).find('.loading-gif').hide(); + }); + $('.update-notifications').on('ajax:success', function(e, data) { + if (data.saved) { + return new Flash("Notification settings saved", "notice"); + } else { + return new Flash("Failed to save new settings", "alert"); + } + }); + this.bindEvents(); + cropOpts = { + filename: '.js-avatar-filename', + previewImage: '.avatar-image .avatar', + modalCrop: '.modal-profile-crop', + pickImageEl: '.js-choose-user-avatar-button', + uploadImageBtn: '.js-upload-user-avatar', + modalCropImg: '.modal-profile-crop-image' + }; + this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop'); + } + + Profile.prototype.bindEvents = function() { + return this.form.on('submit', this.onSubmitForm); + }; + + Profile.prototype.onSubmitForm = function(e) { + e.preventDefault(); + return this.saveForm(); + }; + + Profile.prototype.saveForm = function() { + var avatarBlob, formData, self; + self = this; + formData = new FormData(this.form[0]); + avatarBlob = this.avatarGlCrop.getBlob(); + if (avatarBlob != null) { + formData.append('user[avatar]', avatarBlob, 'avatar.png'); + } + return $.ajax({ + url: this.form.attr('action'), + type: this.form.attr('method'), + data: formData, + dataType: "json", + processData: false, + contentType: false, + success: function(response) { + return new Flash(response.message, 'notice'); + }, + error: function(jqXHR) { + return new Flash(jqXHR.responseJSON.message, 'alert'); + }, + complete: function() { + window.scrollTo(0, 0); + return self.form.find(':input[disabled]').enable(); + } + }); + }; + + return Profile; + + })(); + + $(function() { + $(document).on('focusout.ssh_key', '#key_key', function() { + var $title, comment; + $title = $('#key_title'); + comment = $(this).val().match(/^\S+ \S+ (.+)\n?$/); + if (comment && comment.length > 1 && $title.val() === '') { + return $title.val(comment[1]).change(); + } + }); + if (gl.utils.getPagePath() === 'profiles') { + return new Profile(); + } + }); + +}).call(this); diff --git a/app/assets/javascripts/profile/profile.js.coffee b/app/assets/javascripts/profile/profile.js.coffee deleted file mode 100644 index f3b05f2c646..00000000000 --- a/app/assets/javascripts/profile/profile.js.coffee +++ /dev/null @@ -1,83 +0,0 @@ -class @Profile - constructor: (opts = {}) -> - { - @form = $('.edit-user') - } = opts - - # Automatically submit the Preferences form when any of its radio buttons change - $('.js-preferences-form').on 'change.preference', 'input[type=radio]', -> - $(this).parents('form').submit() - - # Automatically submit email form when it changes - $('#user_notification_email').on 'change', -> - $(this).parents('form').submit() - - $('.update-username').on 'ajax:before', -> - $('.loading-username').show() - $(this).find('.update-success').hide() - $(this).find('.update-failed').hide() - - $('.update-username').on 'ajax:complete', -> - $('.loading-username').hide() - $(this).find('.btn-save').enable() - $(this).find('.loading-gif').hide() - - $('.update-notifications').on 'ajax:success', (e, data) -> - if data.saved - new Flash("Notification settings saved", "notice") - else - new Flash("Failed to save new settings", "alert") - - @bindEvents() - - cropOpts = - filename: '.js-avatar-filename' - previewImage: '.avatar-image .avatar' - modalCrop: '.modal-profile-crop' - pickImageEl: '.js-choose-user-avatar-button' - uploadImageBtn: '.js-upload-user-avatar' - modalCropImg: '.modal-profile-crop-image' - - @avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data 'glcrop' - - bindEvents: -> - @form.on 'submit', @onSubmitForm - - onSubmitForm: (e) => - e.preventDefault() - @saveForm() - - saveForm: -> - self = @ - formData = new FormData(@form[0]) - - avatarBlob = @avatarGlCrop.getBlob() - formData.append('user[avatar]', avatarBlob, 'avatar.png') if avatarBlob? - - $.ajax - url: @form.attr('action') - type: @form.attr('method') - data: formData - dataType: "json" - processData: false - contentType: false - success: (response) -> - new Flash(response.message, 'notice') - error: (jqXHR) -> - new Flash(jqXHR.responseJSON.message, 'alert') - complete: -> - window.scrollTo 0, 0 - # Enable submit button after requests ends - self.form.find(':input[disabled]').enable() - -$ -> - # Extract the SSH Key title from its comment - $(document).on 'focusout.ssh_key', '#key_key', -> - $title = $('#key_title') - comment = $(@).val().match(/^\S+ \S+ (.+)\n?$/) - - if comment && comment.length > 1 && $title.val() == '' - $title.val(comment[1]).change() - - if gl.utils.getPagePath() == 'profiles' - new Profile() diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js new file mode 100644 index 00000000000..b95faadc8e7 --- /dev/null +++ b/app/assets/javascripts/profile/profile_bundle.js @@ -0,0 +1,7 @@ + +/*= require_tree . */ + +(function() { + + +}).call(this); diff --git a/app/assets/javascripts/profile/profile_bundle.js.coffee b/app/assets/javascripts/profile/profile_bundle.js.coffee deleted file mode 100644 index 91cacfece46..00000000000 --- a/app/assets/javascripts/profile/profile_bundle.js.coffee +++ /dev/null @@ -1,2 +0,0 @@ -# -#= require_tree . diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js new file mode 100644 index 00000000000..b97f6d22715 --- /dev/null +++ b/app/assets/javascripts/project.js @@ -0,0 +1,109 @@ +(function() { + this.Project = (function() { + function Project() { + $('ul.clone-options-dropdown a').click(function() { + var url; + if ($(this).hasClass('active')) { + return; + } + $('.active').not($(this)).removeClass('active'); + $(this).toggleClass('active'); + url = $("#project_clone").val(); + $('#project_clone').val(url); + return $('.clone').text(url); + }); + this.initRefSwitcher(); + $('.project-refs-select').on('change', function() { + return $(this).parents('form').submit(); + }); + $('.hide-no-ssh-message').on('click', function(e) { + var path; + path = '/'; + $.cookie('hide_no_ssh_message', 'false', { + path: path + }); + $(this).parents('.no-ssh-key-message').remove(); + return e.preventDefault(); + }); + $('.hide-no-password-message').on('click', function(e) { + var path; + path = '/'; + $.cookie('hide_no_password_message', 'false', { + path: path + }); + $(this).parents('.no-password-message').remove(); + return e.preventDefault(); + }); + this.projectSelectDropdown(); + } + + Project.prototype.projectSelectDropdown = function() { + new ProjectSelect(); + $('.project-item-select').on('click', (function(_this) { + return function(e) { + return _this.changeProject($(e.currentTarget).val()); + }; + })(this)); + return $('.js-projects-dropdown-toggle').on('click', function(e) { + e.preventDefault(); + return $('.js-projects-dropdown').select2('open'); + }); + }; + + Project.prototype.changeProject = function(url) { + return window.location = url; + }; + + Project.prototype.initRefSwitcher = function() { + return $('.js-project-refs-dropdown').each(function() { + var $dropdown, selected; + $dropdown = $(this); + selected = $dropdown.data('selected'); + return $dropdown.glDropdown({ + data: function(term, callback) { + return $.ajax({ + url: $dropdown.data('refs-url'), + data: { + ref: $dropdown.data('ref') + } + }).done(function(refs) { + return callback(refs); + }); + }, + selectable: true, + filterable: true, + filterByText: true, + fieldName: 'ref', + renderRow: function(ref) { + var link; + if (ref.header != null) { + return $('<li />').addClass('dropdown-header').text(ref.header); + } else { + link = $('<a />').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref)); + return $('<li />').append(link); + } + }, + id: function(obj, $el) { + return $el.attr('data-ref'); + }, + toggleLabel: function(obj, $el) { + return $el.text().trim(); + }, + clicked: function(selected, $el, e) { + e.preventDefault() + if ($('input[name="ref"]').length) { + var $form = $dropdown.closest('form'), + action = $form.attr('action'), + divider = action.indexOf('?') < 0 ? '?' : '&'; + Turbolinks.visit(action + '' + divider + '' + $form.serialize()); + } + } + }); + }); + }; + + return Project; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project.js.coffee b/app/assets/javascripts/project.js.coffee deleted file mode 100644 index 3288c801388..00000000000 --- a/app/assets/javascripts/project.js.coffee +++ /dev/null @@ -1,91 +0,0 @@ -class @Project - constructor: -> - # Git protocol switcher - $('ul.clone-options-dropdown a').click -> - return if $(@).hasClass('active') - - - # Remove the active class for all buttons (ssh, http, kerberos if shown) - $('.active').not($(@)).removeClass('active'); - # Add the active class for the clicked button - $(@).toggleClass('active') - - url = $("#project_clone").val() - - # Update the input field - $('#project_clone').val(url) - - # Update the command line instructions - $('.clone').text(url) - - # Ref switcher - @initRefSwitcher() - $('.project-refs-select').on 'change', -> - $(@).parents('form').submit() - - $('.hide-no-ssh-message').on 'click', (e) -> - path = '/' - $.cookie('hide_no_ssh_message', 'false', { path: path }) - $(@).parents('.no-ssh-key-message').remove() - e.preventDefault() - - $('.hide-no-password-message').on 'click', (e) -> - path = '/' - $.cookie('hide_no_password_message', 'false', { path: path }) - $(@).parents('.no-password-message').remove() - e.preventDefault() - - @projectSelectDropdown() - - projectSelectDropdown: -> - new ProjectSelect() - - $('.project-item-select').on 'click', (e) => - @changeProject $(e.currentTarget).val() - - $('.js-projects-dropdown-toggle').on 'click', (e) -> - e.preventDefault() - - $('.js-projects-dropdown').select2('open') - - changeProject: (url) -> - window.location = url - - initRefSwitcher: -> - $('.js-project-refs-dropdown').each -> - $dropdown = $(@) - selected = $dropdown.data('selected') - - $dropdown.glDropdown( - data: (term, callback) -> - $.ajax( - url: $dropdown.data('refs-url') - data: - ref: $dropdown.data('ref') - ).done (refs) -> - callback(refs) - selectable: true - filterable: true - filterByText: true - fieldName: 'ref' - renderRow: (ref) -> - if ref.header? - $('<li />') - .addClass('dropdown-header') - .text(ref.header) - else - link = $('<a />') - .attr('href', '#') - .addClass(if ref is selected then 'is-active' else '') - .text(ref) - .attr('data-ref', escape(ref)) - - $('<li />') - .append(link) - id: (obj, $el) -> - $el.attr('data-ref') - toggleLabel: (obj, $el) -> - $el.text().trim() - clicked: (e) -> - $dropdown.closest('form').submit() - ) diff --git a/app/assets/javascripts/project_avatar.js b/app/assets/javascripts/project_avatar.js new file mode 100644 index 00000000000..277e71523d5 --- /dev/null +++ b/app/assets/javascripts/project_avatar.js @@ -0,0 +1,21 @@ +(function() { + this.ProjectAvatar = (function() { + function ProjectAvatar() { + $('.js-choose-project-avatar-button').bind('click', function() { + var form; + form = $(this).closest('form'); + return form.find('.js-project-avatar-input').click(); + }); + $('.js-project-avatar-input').bind('change', function() { + var filename, form; + form = $(this).closest('form'); + filename = $(this).val().replace(/^.*[\\\/]/, ''); + return form.find('.js-avatar-filename').text(filename); + }); + } + + return ProjectAvatar; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project_avatar.js.coffee b/app/assets/javascripts/project_avatar.js.coffee deleted file mode 100644 index 8bec6e2ccca..00000000000 --- a/app/assets/javascripts/project_avatar.js.coffee +++ /dev/null @@ -1,9 +0,0 @@ -class @ProjectAvatar - constructor: -> - $('.js-choose-project-avatar-button').bind 'click', -> - form = $(this).closest('form') - form.find('.js-project-avatar-input').click() - $('.js-project-avatar-input').bind 'change', -> - form = $(this).closest('form') - filename = $(this).val().replace(/^.*[\\\/]/, '') - form.find('.js-avatar-filename').text(filename) diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js new file mode 100644 index 00000000000..4925f0519f0 --- /dev/null +++ b/app/assets/javascripts/project_find_file.js @@ -0,0 +1,170 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.ProjectFindFile = (function() { + var highlighter; + + function ProjectFindFile(element1, options) { + this.element = element1; + this.options = options; + this.goToBlob = bind(this.goToBlob, this); + this.goToTree = bind(this.goToTree, this); + this.selectRowDown = bind(this.selectRowDown, this); + this.selectRowUp = bind(this.selectRowUp, this); + this.filePaths = {}; + this.inputElement = this.element.find(".file-finder-input"); + this.initEvent(); + this.inputElement.focus(); + this.load(this.options.url); + } + + ProjectFindFile.prototype.initEvent = function() { + this.inputElement.off("keyup"); + this.inputElement.on("keyup", (function(_this) { + return function(event) { + var oldValue, ref, target, value; + target = $(event.target); + value = target.val(); + oldValue = (ref = target.data("oldValue")) != null ? ref : ""; + if (value !== oldValue) { + target.data("oldValue", value); + _this.findFile(); + return _this.element.find("tr.tree-item").eq(0).addClass("selected").focus(); + } + }; + })(this)); + return this.element.find(".tree-content-holder .tree-table").on("click", function(event) { + var path; + if (event.target.nodeName !== "A") { + path = this.element.find(".tree-item-file-name a", this).attr("href"); + if (path) { + return location.href = path; + } + } + }); + }; + + ProjectFindFile.prototype.findFile = function() { + var result, searchText; + searchText = this.inputElement.val(); + result = searchText.length > 0 ? fuzzaldrinPlus.filter(this.filePaths, searchText) : this.filePaths; + return this.renderList(result, searchText); + }; + + ProjectFindFile.prototype.load = function(url) { + return $.ajax({ + url: url, + method: "get", + dataType: "json", + success: (function(_this) { + return function(data) { + _this.element.find(".loading").hide(); + _this.filePaths = data; + _this.findFile(); + return _this.element.find(".files-slider tr.tree-item").eq(0).addClass("selected").focus(); + }; + })(this) + }); + }; + + ProjectFindFile.prototype.renderList = function(filePaths, searchText) { + var blobItemUrl, filePath, html, i, j, len, matches, results; + this.element.find(".tree-table > tbody").empty(); + results = []; + for (i = j = 0, len = filePaths.length; j < len; i = ++j) { + filePath = filePaths[i]; + if (i === 20) { + break; + } + if (searchText) { + matches = fuzzaldrinPlus.match(filePath, searchText); + } + blobItemUrl = this.options.blobUrlTemplate + "/" + filePath; + html = this.makeHtml(filePath, matches, blobItemUrl); + results.push(this.element.find(".tree-table > tbody").append(html)); + } + return results; + }; + + highlighter = function(element, text, matches) { + var highlightText, j, lastIndex, len, matchIndex, matchedChars, unmatched; + lastIndex = 0; + highlightText = ""; + matchedChars = []; + for (j = 0, len = matches.length; j < len; j++) { + matchIndex = matches[j]; + unmatched = text.substring(lastIndex, matchIndex); + if (unmatched) { + if (matchedChars.length) { + element.append(matchedChars.join("").bold()); + } + matchedChars = []; + element.append(document.createTextNode(unmatched)); + } + matchedChars.push(text[matchIndex]); + lastIndex = matchIndex + 1; + } + if (matchedChars.length) { + element.append(matchedChars.join("").bold()); + } + return element.append(document.createTextNode(text.substring(lastIndex))); + }; + + ProjectFindFile.prototype.makeHtml = function(filePath, matches, blobItemUrl) { + var $tr; + $tr = $("<tr class='tree-item'><td class='tree-item-file-name'><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'><a></a></span></td></tr>"); + if (matches) { + $tr.find("a").replaceWith(highlighter($tr.find("a"), filePath, matches).attr("href", blobItemUrl)); + } else { + $tr.find("a").attr("href", blobItemUrl).text(filePath); + } + return $tr; + }; + + ProjectFindFile.prototype.selectRow = function(type) { + var next, rows, selectedRow; + rows = this.element.find(".files-slider tr.tree-item"); + selectedRow = this.element.find(".files-slider tr.tree-item.selected"); + if (rows && rows.length > 0) { + if (selectedRow && selectedRow.length > 0) { + if (type === "UP") { + next = selectedRow.prev(); + } else if (type === "DOWN") { + next = selectedRow.next(); + } + if (next.length > 0) { + selectedRow.removeClass("selected"); + selectedRow = next; + } + } else { + selectedRow = rows.eq(0); + } + return selectedRow.addClass("selected").focus(); + } + }; + + ProjectFindFile.prototype.selectRowUp = function() { + return this.selectRow("UP"); + }; + + ProjectFindFile.prototype.selectRowDown = function() { + return this.selectRow("DOWN"); + }; + + ProjectFindFile.prototype.goToTree = function() { + return location.href = this.options.treeUrl; + }; + + ProjectFindFile.prototype.goToBlob = function() { + var path; + path = this.element.find(".tree-item.selected .tree-item-file-name a").attr("href"); + if (path) { + return location.href = path; + } + }; + + return ProjectFindFile; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project_find_file.js.coffee b/app/assets/javascripts/project_find_file.js.coffee deleted file mode 100644 index 0dd32352c34..00000000000 --- a/app/assets/javascripts/project_find_file.js.coffee +++ /dev/null @@ -1,125 +0,0 @@ -class @ProjectFindFile
- constructor: (@element, @options)->
- @filePaths = {}
- @inputElement = @element.find(".file-finder-input")
-
- # init event
- @initEvent()
-
- # focus text input box
- @inputElement.focus()
-
- # load file list
- @load(@options.url)
-
- # init event
- initEvent: ->
- @inputElement.off "keyup"
- @inputElement.on "keyup", (event) =>
- target = $(event.target)
- value = target.val()
- oldValue = target.data("oldValue") ? ""
-
- if value != oldValue
- target.data("oldValue", value)
- @findFile()
- @element.find("tr.tree-item").eq(0).addClass("selected").focus()
-
- @element.find(".tree-content-holder .tree-table").on "click", (event) ->
- if (event.target.nodeName != "A")
- path = @element.find(".tree-item-file-name a", this).attr("href")
- location.href = path if path
-
- # find file
- findFile: ->
- searchText = @inputElement.val()
- result = if searchText.length > 0 then fuzzaldrinPlus.filter(@filePaths, searchText) else @filePaths
- @renderList result, searchText
-
- # files pathes load
- load: (url) ->
- $.ajax
- url: url
- method: "get"
- dataType: "json"
- success: (data) =>
- @element.find(".loading").hide()
- @filePaths = data
- @findFile()
- @element.find(".files-slider tr.tree-item").eq(0).addClass("selected").focus()
-
- # render result
- renderList: (filePaths, searchText) ->
- @element.find(".tree-table > tbody").empty()
-
- for filePath, i in filePaths
- break if i == 20
-
- if searchText
- matches = fuzzaldrinPlus.match(filePath, searchText)
-
- blobItemUrl = "#{@options.blobUrlTemplate}/#{filePath}"
-
- html = @makeHtml filePath, matches, blobItemUrl
- @element.find(".tree-table > tbody").append(html)
-
- # highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> )
- highlighter = (element, text, matches) ->
- lastIndex = 0
- highlightText = ""
- matchedChars = []
-
- for matchIndex in matches
- unmatched = text.substring(lastIndex, matchIndex)
-
- if unmatched
- element.append(matchedChars.join("").bold()) if matchedChars.length
- matchedChars = []
- element.append(document.createTextNode(unmatched))
-
- matchedChars.push(text[matchIndex])
- lastIndex = matchIndex + 1
-
- element.append(matchedChars.join("").bold()) if matchedChars.length
- element.append(document.createTextNode(text.substring(lastIndex)))
-
- # make tbody row html
- makeHtml: (filePath, matches, blobItemUrl) ->
- $tr = $("<tr class='tree-item'><td class='tree-item-file-name'><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'><a></a></span></td></tr>")
- if matches
- $tr.find("a").replaceWith(highlighter($tr.find("a"), filePath, matches).attr("href", blobItemUrl))
- else
- $tr.find("a").attr("href", blobItemUrl).text(filePath)
-
- return $tr
-
- selectRow: (type) ->
- rows = @element.find(".files-slider tr.tree-item")
- selectedRow = @element.find(".files-slider tr.tree-item.selected")
-
- if rows && rows.length > 0
- if selectedRow && selectedRow.length > 0
- if type == "UP"
- next = selectedRow.prev()
- else if type == "DOWN"
- next = selectedRow.next()
-
- if next.length > 0
- selectedRow.removeClass "selected"
- selectedRow = next
- else
- selectedRow = rows.eq(0)
- selectedRow.addClass("selected").focus()
-
- selectRowUp: =>
- @selectRow "UP"
-
- selectRowDown: =>
- @selectRow "DOWN"
-
- goToTree: =>
- location.href = @options.treeUrl
-
- goToBlob: =>
- path = @element.find(".tree-item.selected .tree-item-file-name a").attr("href")
- location.href = path if path
diff --git a/app/assets/javascripts/project_fork.js b/app/assets/javascripts/project_fork.js new file mode 100644 index 00000000000..d2261c51f35 --- /dev/null +++ b/app/assets/javascripts/project_fork.js @@ -0,0 +1,14 @@ +(function() { + this.ProjectFork = (function() { + function ProjectFork() { + $('.fork-thumbnail a').on('click', function() { + $('.fork-namespaces').hide(); + return $('.save-project-loader').show(); + }); + } + + return ProjectFork; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project_fork.js.coffee b/app/assets/javascripts/project_fork.js.coffee deleted file mode 100644 index e15a1c4ef76..00000000000 --- a/app/assets/javascripts/project_fork.js.coffee +++ /dev/null @@ -1,5 +0,0 @@ -class @ProjectFork - constructor: -> - $('.fork-thumbnail a').on 'click', -> - $('.fork-namespaces').hide() - $('.save-project-loader').show() diff --git a/app/assets/javascripts/project_import.js b/app/assets/javascripts/project_import.js new file mode 100644 index 00000000000..c61b0cf2fde --- /dev/null +++ b/app/assets/javascripts/project_import.js @@ -0,0 +1,13 @@ +(function() { + this.ProjectImport = (function() { + function ProjectImport() { + setTimeout(function() { + return Turbolinks.visit(location.href); + }, 5000); + } + + return ProjectImport; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project_import.js.coffee b/app/assets/javascripts/project_import.js.coffee deleted file mode 100644 index 6633564a079..00000000000 --- a/app/assets/javascripts/project_import.js.coffee +++ /dev/null @@ -1,5 +0,0 @@ -class @ProjectImport - constructor: -> - setTimeout -> - Turbolinks.visit(location.href) - , 5000 diff --git a/app/assets/javascripts/project_members.js b/app/assets/javascripts/project_members.js new file mode 100644 index 00000000000..f6a796b325a --- /dev/null +++ b/app/assets/javascripts/project_members.js @@ -0,0 +1,13 @@ +(function() { + this.ProjectMembers = (function() { + function ProjectMembers() { + $('li.project_member').bind('ajax:success', function() { + return $(this).fadeOut(); + }); + } + + return ProjectMembers; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project_members.js.coffee b/app/assets/javascripts/project_members.js.coffee deleted file mode 100644 index 896ba7e53ee..00000000000 --- a/app/assets/javascripts/project_members.js.coffee +++ /dev/null @@ -1,4 +0,0 @@ -class @ProjectMembers - constructor: -> - $('li.project_member').bind 'ajax:success', -> - $(this).fadeOut() diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js new file mode 100644 index 00000000000..798f15e40a0 --- /dev/null +++ b/app/assets/javascripts/project_new.js @@ -0,0 +1,40 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.ProjectNew = (function() { + function ProjectNew() { + this.toggleSettings = bind(this.toggleSettings, this); + $('.project-edit-container').on('ajax:before', (function(_this) { + return function() { + $('.project-edit-container').hide(); + return $('.save-project-loader').show(); + }; + })(this)); + this.toggleSettings(); + this.toggleSettingsOnclick(); + } + + ProjectNew.prototype.toggleSettings = function() { + this._showOrHide('#project_builds_enabled', '.builds-feature'); + return this._showOrHide('#project_merge_requests_enabled', '.merge-requests-feature'); + }; + + ProjectNew.prototype.toggleSettingsOnclick = function() { + return $('#project_builds_enabled, #project_merge_requests_enabled').on('click', this.toggleSettings); + }; + + ProjectNew.prototype._showOrHide = function(checkElement, container) { + var $container; + $container = $(container); + if ($(checkElement).prop('checked')) { + return $container.show(); + } else { + return $container.hide(); + } + }; + + return ProjectNew; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project_new.js.coffee b/app/assets/javascripts/project_new.js.coffee deleted file mode 100644 index e48343a19b5..00000000000 --- a/app/assets/javascripts/project_new.js.coffee +++ /dev/null @@ -1,23 +0,0 @@ -class @ProjectNew - constructor: -> - $('.project-edit-container').on 'ajax:before', => - $('.project-edit-container').hide() - $('.save-project-loader').show() - @toggleSettings() - @toggleSettingsOnclick() - - - toggleSettings: => - @_showOrHide('#project_builds_enabled', '.builds-feature') - @_showOrHide('#project_merge_requests_enabled', '.merge-requests-feature') - - toggleSettingsOnclick: -> - $('#project_builds_enabled, #project_merge_requests_enabled').on 'click', @toggleSettings - - _showOrHide: (checkElement, container) -> - $container = $(container) - - if $(checkElement).prop('checked') - $container.show() - else - $container.hide() diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js new file mode 100644 index 00000000000..20b147500cf --- /dev/null +++ b/app/assets/javascripts/project_select.js @@ -0,0 +1,102 @@ +(function() { + this.ProjectSelect = (function() { + function ProjectSelect() { + $('.js-projects-dropdown-toggle').each(function(i, dropdown) { + var $dropdown; + $dropdown = $(dropdown); + return $dropdown.glDropdown({ + filterable: true, + filterRemote: true, + search: { + fields: ['name_with_namespace'] + }, + data: function(term, callback) { + var finalCallback, projectsCallback; + finalCallback = function(projects) { + return callback(projects); + }; + if (this.includeGroups) { + projectsCallback = function(projects) { + var groupsCallback; + groupsCallback = function(groups) { + var data; + data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(term, false, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (this.groupId) { + return Api.groupProjects(this.groupId, term, projectsCallback); + } else { + return Api.projects(term, this.orderBy, projectsCallback); + } + }, + url: function(project) { + return project.web_url; + }, + text: function(project) { + return project.name_with_namespace; + } + }); + }); + $('.ajax-project-select').each(function(i, select) { + var placeholder; + this.groupId = $(select).data('group-id'); + this.includeGroups = $(select).data('include-groups'); + this.orderBy = $(select).data('order-by') || 'id'; + placeholder = "Search for project"; + if (this.includeGroups) { + placeholder += " or group"; + } + return $(select).select2({ + placeholder: placeholder, + minimumInputLength: 0, + query: (function(_this) { + return function(query) { + var finalCallback, projectsCallback; + finalCallback = function(projects) { + var data; + data = { + results: projects + }; + return query.callback(data); + }; + if (_this.includeGroups) { + projectsCallback = function(projects) { + var groupsCallback; + groupsCallback = function(groups) { + var data; + data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, false, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (_this.groupId) { + return Api.groupProjects(_this.groupId, query.term, projectsCallback); + } else { + return Api.projects(query.term, _this.orderBy, projectsCallback); + } + }; + })(this), + id: function(project) { + return project.web_url; + }, + text: function(project) { + return project.name_with_namespace || project.name; + }, + dropdownCssClass: "ajax-project-dropdown" + }); + }); + } + + return ProjectSelect; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project_select.js.coffee b/app/assets/javascripts/project_select.js.coffee deleted file mode 100644 index 704bd8dee53..00000000000 --- a/app/assets/javascripts/project_select.js.coffee +++ /dev/null @@ -1,72 +0,0 @@ -class @ProjectSelect - constructor: -> - $('.js-projects-dropdown-toggle').each (i, dropdown) -> - $dropdown = $(dropdown) - - $dropdown.glDropdown( - filterable: true - filterRemote: true - search: - fields: ['name_with_namespace'] - data: (term, callback) -> - finalCallback = (projects) -> - callback projects - - if @includeGroups - projectsCallback = (projects) -> - groupsCallback = (groups) -> - data = groups.concat(projects) - finalCallback(data) - - Api.groups term, false, groupsCallback - else - projectsCallback = finalCallback - - if @groupId - Api.groupProjects @groupId, term, projectsCallback - else - Api.projects term, @orderBy, projectsCallback - url: (project) -> - project.web_url - text: (project) -> - project.name_with_namespace - ) - - $('.ajax-project-select').each (i, select) -> - @groupId = $(select).data('group-id') - @includeGroups = $(select).data('include-groups') - @orderBy = $(select).data('order-by') || 'id' - - placeholder = "Search for project" - placeholder += " or group" if @includeGroups - - $(select).select2 - placeholder: placeholder - minimumInputLength: 0 - query: (query) => - finalCallback = (projects) -> - data = { results: projects } - query.callback(data) - - if @includeGroups - projectsCallback = (projects) -> - groupsCallback = (groups) -> - data = groups.concat(projects) - finalCallback(data) - - Api.groups query.term, false, groupsCallback - else - projectsCallback = finalCallback - - if @groupId - Api.groupProjects @groupId, query.term, projectsCallback - else - Api.projects query.term, @orderBy, projectsCallback - - id: (project) -> - project.web_url - - text: (project) -> - project.name_with_namespace || project.name - - dropdownCssClass: "ajax-project-dropdown" diff --git a/app/assets/javascripts/project_show.js b/app/assets/javascripts/project_show.js new file mode 100644 index 00000000000..8ca4c427912 --- /dev/null +++ b/app/assets/javascripts/project_show.js @@ -0,0 +1,9 @@ +(function() { + this.ProjectShow = (function() { + function ProjectShow() {} + + return ProjectShow; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/project_show.js.coffee b/app/assets/javascripts/project_show.js.coffee deleted file mode 100644 index 1fdf28f2528..00000000000 --- a/app/assets/javascripts/project_show.js.coffee +++ /dev/null @@ -1,3 +0,0 @@ -class @ProjectShow - constructor: -> - # I kept class for future diff --git a/app/assets/javascripts/projects_list.js b/app/assets/javascripts/projects_list.js new file mode 100644 index 00000000000..4f415b05dbc --- /dev/null +++ b/app/assets/javascripts/projects_list.js @@ -0,0 +1,48 @@ +(function() { + this.ProjectsList = { + init: function() { + $(".projects-list-filter").off('keyup'); + this.initSearch(); + return this.initPagination(); + }, + initSearch: function() { + var debounceFilter, projectsListFilter; + projectsListFilter = $('.projects-list-filter'); + debounceFilter = _.debounce(ProjectsList.filterResults, 500); + return projectsListFilter.on('keyup', function(e) { + if (projectsListFilter.val() !== '') { + return debounceFilter(); + } + }); + }, + filterResults: function() { + var form, project_filter_url, search; + $('.projects-list-holder').fadeTo(250, 0.5); + form = null; + form = $("form#project-filter-form"); + search = $(".projects-list-filter").val(); + project_filter_url = form.attr('action') + '?' + form.serialize(); + return $.ajax({ + type: "GET", + url: form.attr('action'), + data: form.serialize(), + complete: function() { + return $('.projects-list-holder').fadeTo(250, 1); + }, + success: function(data) { + $('.projects-list-holder').replaceWith(data.html); + return history.replaceState({ + page: project_filter_url + }, document.title, project_filter_url); + }, + dataType: "json" + }); + }, + initPagination: function() { + return $('.projects-list-holder .pagination').on('ajax:success', function(e, data) { + return $('.projects-list-holder').replaceWith(data.html); + }); + } + }; + +}).call(this); diff --git a/app/assets/javascripts/projects_list.js.coffee b/app/assets/javascripts/projects_list.js.coffee deleted file mode 100644 index a7d78d9e461..00000000000 --- a/app/assets/javascripts/projects_list.js.coffee +++ /dev/null @@ -1,36 +0,0 @@ -@ProjectsList = - init: -> - $(".projects-list-filter").off('keyup') - this.initSearch() - this.initPagination() - - initSearch: -> - projectsListFilter = $('.projects-list-filter') - debounceFilter = _.debounce ProjectsList.filterResults, 500 - projectsListFilter.on 'keyup', (e) -> - debounceFilter() if projectsListFilter.val() isnt '' - - filterResults: -> - $('.projects-list-holder').fadeTo(250, 0.5) - - form = null - form = $("form#project-filter-form") - search = $(".projects-list-filter").val() - project_filter_url = form.attr('action') + '?' + form.serialize() - - $.ajax - type: "GET" - url: form.attr('action') - data: form.serialize() - complete: -> - $('.projects-list-holder').fadeTo(250, 1) - success: (data) -> - $('.projects-list-holder').replaceWith(data.html) - # Change url so if user reload a page - search results are saved - history.replaceState {page: project_filter_url}, document.title, project_filter_url - dataType: "json" - - initPagination: -> - $('.projects-list-holder .pagination').on('ajax:success', (e, data) -> - $('.projects-list-holder').replaceWith(data.html) - ) diff --git a/app/assets/javascripts/protected_branch_access_dropdown.js.es6 b/app/assets/javascripts/protected_branch_access_dropdown.js.es6 new file mode 100644 index 00000000000..2fbb088fa04 --- /dev/null +++ b/app/assets/javascripts/protected_branch_access_dropdown.js.es6 @@ -0,0 +1,24 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchAccessDropdown = class { + constructor(options) { + const { $dropdown, data, onSelect } = options; + + $dropdown.glDropdown({ + data: data, + selectable: true, + inputId: $dropdown.data('input-id'), + fieldName: $dropdown.data('field-name'), + toggleLabel(item) { + return item.text; + }, + clicked(item, $el, e) { + e.preventDefault(); + onSelect(); + } + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_create.js.es6 b/app/assets/javascripts/protected_branch_create.js.es6 new file mode 100644 index 00000000000..00e20a03b04 --- /dev/null +++ b/app/assets/javascripts/protected_branch_create.js.es6 @@ -0,0 +1,56 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchCreate = class { + constructor() { + this.$wrap = this.$form = $('#new_protected_branch'); + this.buildDropdowns(); + } + + buildDropdowns() { + const $allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); + const $allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); + + // Cache callback + this.onSelectCallback = this.onSelect.bind(this); + + // Allowed to Merge dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: $allowedToMergeDropdown, + data: gon.merge_access_levels, + onSelect: this.onSelectCallback + }); + + // Allowed to Push dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: $allowedToPushDropdown, + data: gon.push_access_levels, + onSelect: this.onSelectCallback + }); + + // Select default + $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0); + $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0); + + // Protected branch dropdown + new ProtectedBranchDropdown({ + $dropdown: this.$wrap.find('.js-protected-branch-select'), + onSelect: this.onSelectCallback + }); + } + + // This will run after clicked callback + onSelect() { + + // Enable submit button + const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]'); + const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_level_attributes][access_level]"]'); + const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_level_attributes][access_level]"]'); + + if ($branchInput.val() && $allowedToMergeInput.val() && $allowedToPushInput.val()){ + this.$form.find('input[type="submit"]').removeAttr('disabled'); + } + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_dropdown.js.es6 b/app/assets/javascripts/protected_branch_dropdown.js.es6 new file mode 100644 index 00000000000..6738dc8862d --- /dev/null +++ b/app/assets/javascripts/protected_branch_dropdown.js.es6 @@ -0,0 +1,75 @@ +class ProtectedBranchDropdown { + constructor(options) { + this.onSelect = options.onSelect; + this.$dropdown = options.$dropdown; + this.$dropdownContainer = this.$dropdown.parent(); + this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); + this.$protectedBranch = this.$dropdownContainer.find('.create-new-protected-branch'); + + this.buildDropdown(); + this.bindEvents(); + + // Hide footer + this.$dropdownFooter.addClass('hidden'); + } + + buildDropdown() { + this.$dropdown.glDropdown({ + data: this.getProtectedBranches.bind(this), + filterable: true, + remote: false, + search: { + fields: ['title'] + }, + selectable: true, + toggleLabel(selected) { + return (selected && 'id' in selected) ? selected.title : 'Protected Branch'; + }, + fieldName: 'protected_branch[name]', + text(protectedBranch) { + return _.escape(protectedBranch.title); + }, + id(protectedBranch) { + return _.escape(protectedBranch.id); + }, + onFilter: this.toggleCreateNewButton.bind(this), + clicked: (item, $el, e) => { + e.preventDefault(); + this.onSelect(); + } + }); + } + + bindEvents() { + this.$protectedBranch.on('click', this.onClickCreateWildcard.bind(this)); + } + + onClickCreateWildcard() { + this.$dropdown.data('glDropdown').remote.execute(); + this.$dropdown.data('glDropdown').selectRowAtIndex(0); + } + + getProtectedBranches(term, callback) { + if (this.selectedBranch) { + callback(gon.open_branches.concat(this.selectedBranch)); + } else { + callback(gon.open_branches); + } + } + + toggleCreateNewButton(branchName) { + this.selectedBranch = { + title: branchName, + id: branchName, + text: branchName + }; + + if (branchName) { + this.$dropdownContainer + .find('.create-new-protected-branch code') + .text(branchName); + } + + this.$dropdownFooter.toggleClass('hidden', !branchName); + } +} diff --git a/app/assets/javascripts/protected_branch_edit.js.es6 b/app/assets/javascripts/protected_branch_edit.js.es6 new file mode 100644 index 00000000000..8d42e268ebc --- /dev/null +++ b/app/assets/javascripts/protected_branch_edit.js.es6 @@ -0,0 +1,61 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchEdit = class { + constructor(options) { + this.$wrap = options.$wrap; + this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); + this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); + + this.buildDropdowns(); + } + + buildDropdowns() { + + // Allowed to merge dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: this.$allowedToMergeDropdown, + data: gon.merge_access_levels, + onSelect: this.onSelect.bind(this) + }); + + // Allowed to push dropdown + new gl.ProtectedBranchAccessDropdown({ + $dropdown: this.$allowedToPushDropdown, + data: gon.push_access_levels, + onSelect: this.onSelect.bind(this) + }); + } + + onSelect() { + const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`); + const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`); + + $.ajax({ + type: 'POST', + url: this.$wrap.data('url'), + dataType: 'json', + data: { + _method: 'PATCH', + id: this.$wrap.data('banchId'), + protected_branch: { + merge_access_level_attributes: { + access_level: $allowedToMergeInput.val() + }, + push_access_level_attributes: { + access_level: $allowedToPushInput.val() + } + } + }, + success: () => { + this.$wrap.effect('highlight'); + }, + error() { + $.scrollTo(0); + new Flash('Failed to update branch!'); + } + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_edit_list.js.es6 b/app/assets/javascripts/protected_branch_edit_list.js.es6 new file mode 100644 index 00000000000..9ff0fd12c76 --- /dev/null +++ b/app/assets/javascripts/protected_branch_edit_list.js.es6 @@ -0,0 +1,17 @@ +(global => { + global.gl = global.gl || {}; + + gl.ProtectedBranchEditList = class { + constructor() { + this.$wrap = $('.protected-branches-list'); + + // Build edit forms + this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => { + new gl.ProtectedBranchEdit({ + $wrap: $(el) + }); + }); + } + } + +})(window); diff --git a/app/assets/javascripts/protected_branch_select.js.coffee b/app/assets/javascripts/protected_branch_select.js.coffee deleted file mode 100644 index 6d45770ace9..00000000000 --- a/app/assets/javascripts/protected_branch_select.js.coffee +++ /dev/null @@ -1,40 +0,0 @@ -class @ProtectedBranchSelect - constructor: (currentProject) -> - $('.dropdown-footer').hide(); - @dropdown = $('.js-protected-branch-select').glDropdown( - data: @getProtectedBranches - filterable: true - remote: false - search: - fields: ['title'] - selectable: true - toggleLabel: (selected) -> if (selected and 'id' of selected) then selected.title else 'Protected Branch' - fieldName: 'protected_branch[name]' - text: (protected_branch) -> _.escape(protected_branch.title) - id: (protected_branch) -> _.escape(protected_branch.id) - onFilter: @toggleCreateNewButton - clicked: () -> $('.protect-branch-btn').attr('disabled', false) - ) - - $('.create-new-protected-branch').on 'click', (event) => - # Refresh the dropdown's data, which ends up calling `getProtectedBranches` - @dropdown.data('glDropdown').remote.execute() - @dropdown.data('glDropdown').selectRowAtIndex(event, 0) - - getProtectedBranches: (term, callback) => - if @selectedBranch - callback(gon.open_branches.concat(@selectedBranch)) - else - callback(gon.open_branches) - - toggleCreateNewButton: (branchName) => - @selectedBranch = { title: branchName, id: branchName, text: branchName } - - if branchName is '' - $('.protected-branch-select-footer-list').addClass('hidden') - $('.dropdown-footer').hide(); - else - $('.create-new-protected-branch').text("Create Protected Branch: #{branchName}") - $('.protected-branch-select-footer-list').removeClass('hidden') - $('.dropdown-footer').show(); - diff --git a/app/assets/javascripts/protected_branches.js.coffee b/app/assets/javascripts/protected_branches.js.coffee deleted file mode 100644 index 14afef2e2ee..00000000000 --- a/app/assets/javascripts/protected_branches.js.coffee +++ /dev/null @@ -1,22 +0,0 @@ -$ -> - $(".protected-branches-list :checkbox").change (e) -> - name = $(this).attr("name") - if name == "developers_can_push" || name == "developers_can_merge" - id = $(this).val() - can_push = $(this).is(":checked") - url = $(this).data("url") - $.ajax - type: "PATCH" - url: url - dataType: "json" - data: - id: id - protected_branch: - "#{name}": can_push - - success: -> - row = $(e.target) - row.closest('tr').effect('highlight') - - error: -> - new Flash("Failed to update branch!", "alert") diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js new file mode 100644 index 00000000000..dc4d5113826 --- /dev/null +++ b/app/assets/javascripts/right_sidebar.js @@ -0,0 +1,201 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Sidebar = (function() { + function Sidebar(currentUser) { + this.toggleTodo = bind(this.toggleTodo, this); + this.sidebar = $('aside'); + this.addEventListeners(); + } + + Sidebar.prototype.addEventListeners = function() { + this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked); + $('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden); + $('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading); + $('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded); + $(document).off('click', '.js-sidebar-toggle').on('click', '.js-sidebar-toggle', function(e, triggered) { + var $allGutterToggleIcons, $this, $thisIcon; + e.preventDefault(); + $this = $(this); + $thisIcon = $this.find('i'); + $allGutterToggleIcons = $('.js-sidebar-toggle i'); + if ($thisIcon.hasClass('fa-angle-double-right')) { + $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); + $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + $('.page-with-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); + } else { + $allGutterToggleIcons.removeClass('fa-angle-double-left').addClass('fa-angle-double-right'); + $('aside.right-sidebar').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded'); + $('.page-with-sidebar').removeClass('right-sidebar-collapsed').addClass('right-sidebar-expanded'); + } + if (!triggered) { + return $.cookie("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed'), { + path: '/' + }); + } + }); + return $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo); + }; + + Sidebar.prototype.toggleTodo = function(e) { + var $btnText, $this, $todoLoading, ajaxType, url; + $this = $(e.currentTarget); + $todoLoading = $('.js-issuable-todo-loading'); + $btnText = $('.js-issuable-todo-text', $this); + ajaxType = $this.attr('data-delete-path') ? 'DELETE' : 'POST'; + if ($this.attr('data-delete-path')) { + url = "" + ($this.attr('data-delete-path')); + } else { + url = "" + ($this.data('url')); + } + return $.ajax({ + url: url, + type: ajaxType, + dataType: 'json', + data: { + issuable_id: $this.data('issuable-id'), + issuable_type: $this.data('issuable-type') + }, + beforeSend: (function(_this) { + return function() { + return _this.beforeTodoSend($this, $todoLoading); + }; + })(this) + }).done((function(_this) { + return function(data) { + return _this.todoUpdateDone(data, $this, $btnText, $todoLoading); + }; + })(this)); + }; + + Sidebar.prototype.beforeTodoSend = function($btn, $todoLoading) { + $btn.disable(); + return $todoLoading.removeClass('hidden'); + }; + + Sidebar.prototype.todoUpdateDone = function(data, $btn, $btnText, $todoLoading) { + var $todoPendingCount; + $todoPendingCount = $('.todos-pending-count'); + $todoPendingCount.text(data.count); + $btn.enable(); + $todoLoading.addClass('hidden'); + if (data.count === 0) { + $todoPendingCount.addClass('hidden'); + } else { + $todoPendingCount.removeClass('hidden'); + } + if (data.delete_path != null) { + $btn.attr('aria-label', $btn.data('mark-text')).attr('data-delete-path', data.delete_path); + return $btnText.text($btn.data('mark-text')); + } else { + $btn.attr('aria-label', $btn.data('todo-text')).removeAttr('data-delete-path'); + return $btnText.text($btn.data('todo-text')); + } + }; + + Sidebar.prototype.sidebarDropdownLoading = function(e) { + var $loading, $sidebarCollapsedIcon, i, img; + $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon'); + img = $sidebarCollapsedIcon.find('img'); + i = $sidebarCollapsedIcon.find('i'); + $loading = $('<i class="fa fa-spinner fa-spin"></i>'); + if (img.length) { + img.before($loading); + return img.hide(); + } else if (i.length) { + i.before($loading); + return i.hide(); + } + }; + + Sidebar.prototype.sidebarDropdownLoaded = function(e) { + var $sidebarCollapsedIcon, i, img; + $sidebarCollapsedIcon = $(this).closest('.block').find('.sidebar-collapsed-icon'); + img = $sidebarCollapsedIcon.find('img'); + $sidebarCollapsedIcon.find('i.fa-spin').remove(); + i = $sidebarCollapsedIcon.find('i'); + if (img.length) { + return img.show(); + } else { + return i.show(); + } + }; + + Sidebar.prototype.sidebarCollapseClicked = function(e) { + var $block, sidebar; + if ($(e.currentTarget).hasClass('dont-change-state')) { + return; + } + sidebar = e.data; + e.preventDefault(); + $block = $(this).closest('.block'); + return sidebar.openDropdown($block); + }; + + Sidebar.prototype.openDropdown = function(blockOrName) { + var $block; + $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; + $block.find('.edit-link').trigger('click'); + if (!this.isOpen()) { + this.setCollapseAfterUpdate($block); + return this.toggleSidebar('open'); + } + }; + + Sidebar.prototype.setCollapseAfterUpdate = function($block) { + $block.addClass('collapse-after-update'); + return $('.page-with-sidebar').addClass('with-overlay'); + }; + + Sidebar.prototype.onSidebarDropdownHidden = function(e) { + var $block, sidebar; + sidebar = e.data; + e.preventDefault(); + $block = $(this).closest('.block'); + return sidebar.sidebarDropdownHidden($block); + }; + + Sidebar.prototype.sidebarDropdownHidden = function($block) { + if ($block.hasClass('collapse-after-update')) { + $block.removeClass('collapse-after-update'); + $('.page-with-sidebar').removeClass('with-overlay'); + return this.toggleSidebar('hide'); + } + }; + + Sidebar.prototype.triggerOpenSidebar = function() { + return this.sidebar.find('.js-sidebar-toggle').trigger('click'); + }; + + Sidebar.prototype.toggleSidebar = function(action) { + if (action == null) { + action = 'toggle'; + } + if (action === 'toggle') { + this.triggerOpenSidebar(); + } + if (action === 'open') { + if (!this.isOpen()) { + this.triggerOpenSidebar(); + } + } + if (action === 'hide') { + if (this.isOpen()) { + return this.triggerOpenSidebar(); + } + } + }; + + Sidebar.prototype.isOpen = function() { + return this.sidebar.is('.right-sidebar-expanded'); + }; + + Sidebar.prototype.getBlock = function(name) { + return this.sidebar.find(".block." + name); + }; + + return Sidebar; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee deleted file mode 100644 index 0c95301e380..00000000000 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ /dev/null @@ -1,175 +0,0 @@ -class @Sidebar - constructor: (currentUser) -> - @sidebar = $('aside') - - @addEventListeners() - - addEventListeners: -> - @sidebar.on('click', '.sidebar-collapsed-icon', @, @sidebarCollapseClicked) - $('.dropdown').on('hidden.gl.dropdown', @, @onSidebarDropdownHidden) - $('.dropdown').on('loading.gl.dropdown', @sidebarDropdownLoading) - $('.dropdown').on('loaded.gl.dropdown', @sidebarDropdownLoaded) - - - $(document) - .off 'click', '.js-sidebar-toggle' - .on 'click', '.js-sidebar-toggle', (e, triggered) -> - e.preventDefault() - $this = $(this) - $thisIcon = $this.find 'i' - $allGutterToggleIcons = $('.js-sidebar-toggle i') - if $thisIcon.hasClass('fa-angle-double-right') - $allGutterToggleIcons - .removeClass('fa-angle-double-right') - .addClass('fa-angle-double-left') - $('aside.right-sidebar') - .removeClass('right-sidebar-expanded') - .addClass('right-sidebar-collapsed') - $('.page-with-sidebar') - .removeClass('right-sidebar-expanded') - .addClass('right-sidebar-collapsed') - else - $allGutterToggleIcons - .removeClass('fa-angle-double-left') - .addClass('fa-angle-double-right') - $('aside.right-sidebar') - .removeClass('right-sidebar-collapsed') - .addClass('right-sidebar-expanded') - $('.page-with-sidebar') - .removeClass('right-sidebar-collapsed') - .addClass('right-sidebar-expanded') - if not triggered - $.cookie("collapsed_gutter", - $('.right-sidebar') - .hasClass('right-sidebar-collapsed'), { path: '/' }) - - $(document) - .off 'click', '.js-issuable-todo' - .on 'click', '.js-issuable-todo', @toggleTodo - - toggleTodo: (e) => - $this = $(e.currentTarget) - $todoLoading = $('.js-issuable-todo-loading') - $btnText = $('.js-issuable-todo-text', $this) - ajaxType = if $this.attr('data-delete-path') then 'DELETE' else 'POST' - - if $this.attr('data-delete-path') - url = "#{$this.attr('data-delete-path')}" - else - url = "#{$this.data('url')}" - - $.ajax( - url: url - type: ajaxType - dataType: 'json' - data: - issuable_id: $this.data('issuable-id') - issuable_type: $this.data('issuable-type') - beforeSend: => - @beforeTodoSend($this, $todoLoading) - ).done (data) => - @todoUpdateDone(data, $this, $btnText, $todoLoading) - - beforeTodoSend: ($btn, $todoLoading) -> - $btn.disable() - $todoLoading.removeClass 'hidden' - - todoUpdateDone: (data, $btn, $btnText, $todoLoading) -> - $todoPendingCount = $('.todos-pending-count') - $todoPendingCount.text data.count - - $btn.enable() - $todoLoading.addClass 'hidden' - - if data.count is 0 - $todoPendingCount.addClass 'hidden' - else - $todoPendingCount.removeClass 'hidden' - - if data.delete_path? - $btn - .attr 'aria-label', $btn.data('mark-text') - .attr 'data-delete-path', data.delete_path - $btnText.text $btn.data('mark-text') - else - $btn - .attr 'aria-label', $btn.data('todo-text') - .removeAttr 'data-delete-path' - $btnText.text $btn.data('todo-text') - - sidebarDropdownLoading: (e) -> - $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') - img = $sidebarCollapsedIcon.find('img') - i = $sidebarCollapsedIcon.find('i') - $loading = $('<i class="fa fa-spinner fa-spin"></i>') - if img.length - img.before($loading) - img.hide() - else if i.length - i.before($loading) - i.hide() - - sidebarDropdownLoaded: (e) -> - $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') - img = $sidebarCollapsedIcon.find('img') - $sidebarCollapsedIcon.find('i.fa-spin').remove() - i = $sidebarCollapsedIcon.find('i') - if img.length - img.show() - else - i.show() - - sidebarCollapseClicked: (e) -> - - return if $(e.currentTarget).hasClass('dont-change-state') - - sidebar = e.data - e.preventDefault() - $block = $(@).closest('.block') - sidebar.openDropdown($block); - - openDropdown: (blockOrName) -> - $block = if _.isString(blockOrName) then @getBlock(blockOrName) else blockOrName - - $block.find('.edit-link').trigger('click') - - if not @isOpen() - @setCollapseAfterUpdate($block) - @toggleSidebar('open') - - setCollapseAfterUpdate: ($block) -> - $block.addClass('collapse-after-update') - $('.page-with-sidebar').addClass('with-overlay') - - onSidebarDropdownHidden: (e) -> - sidebar = e.data - e.preventDefault() - $block = $(@).closest('.block') - sidebar.sidebarDropdownHidden($block) - - sidebarDropdownHidden: ($block) -> - if $block.hasClass('collapse-after-update') - $block.removeClass('collapse-after-update') - $('.page-with-sidebar').removeClass('with-overlay') - @toggleSidebar('hide') - - triggerOpenSidebar: -> - @sidebar - .find('.js-sidebar-toggle') - .trigger('click') - - toggleSidebar: (action = 'toggle') -> - if action is 'toggle' - @triggerOpenSidebar() - - if action is 'open' - @triggerOpenSidebar() if not @isOpen() - - if action is 'hide' - @triggerOpenSidebar() if @isOpen() - - isOpen: -> - @sidebar.is('.right-sidebar-expanded') - - getBlock: (name) -> - @sidebar.find(".block.#{name}") diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js new file mode 100644 index 00000000000..d34346f862b --- /dev/null +++ b/app/assets/javascripts/search.js @@ -0,0 +1,93 @@ +(function() { + this.Search = (function() { + function Search() { + var $groupDropdown, $projectDropdown; + $groupDropdown = $('.js-search-group-dropdown'); + $projectDropdown = $('.js-search-project-dropdown'); + this.eventListeners(); + $groupDropdown.glDropdown({ + selectable: true, + filterable: true, + fieldName: 'group_id', + data: function(term, callback) { + return Api.groups(term, null, function(data) { + data.unshift({ + name: 'Any' + }); + data.splice(1, 0, 'divider'); + return callback(data); + }); + }, + id: function(obj) { + return obj.id; + }, + text: function(obj) { + return obj.name; + }, + toggleLabel: function(obj) { + return ($groupDropdown.data('default-label')) + " " + obj.name; + }, + clicked: (function(_this) { + return function() { + return _this.submitSearch(); + }; + })(this) + }); + $projectDropdown.glDropdown({ + selectable: true, + filterable: true, + fieldName: 'project_id', + data: function(term, callback) { + return Api.projects(term, 'id', function(data) { + data.unshift({ + name_with_namespace: 'Any' + }); + data.splice(1, 0, 'divider'); + return callback(data); + }); + }, + id: function(obj) { + return obj.id; + }, + text: function(obj) { + return obj.name_with_namespace; + }, + toggleLabel: function(obj) { + return ($projectDropdown.data('default-label')) + " " + obj.name_with_namespace; + }, + clicked: (function(_this) { + return function() { + return _this.submitSearch(); + }; + })(this) + }); + } + + Search.prototype.eventListeners = function() { + $(document).off('keyup', '.js-search-input').on('keyup', '.js-search-input', this.searchKeyUp); + return $(document).off('click', '.js-search-clear').on('click', '.js-search-clear', this.clearSearchField); + }; + + Search.prototype.submitSearch = function() { + return $('.js-search-form').submit(); + }; + + Search.prototype.searchKeyUp = function() { + var $input; + $input = $(this); + if ($input.val() === '') { + return $('.js-search-clear').addClass('hidden'); + } else { + return $('.js-search-clear').removeClass('hidden'); + } + }; + + Search.prototype.clearSearchField = function() { + return $('.js-search-input').val('').trigger('keyup').focus(); + }; + + return Search; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/search.js.coffee b/app/assets/javascripts/search.js.coffee deleted file mode 100644 index 661e1195f60..00000000000 --- a/app/assets/javascripts/search.js.coffee +++ /dev/null @@ -1,75 +0,0 @@ -class @Search - constructor: -> - $groupDropdown = $('.js-search-group-dropdown') - $projectDropdown = $('.js-search-project-dropdown') - @eventListeners() - - $groupDropdown.glDropdown( - selectable: true - filterable: true - fieldName: 'group_id' - data: (term, callback) -> - Api.groups term, null, (data) -> - data.unshift( - name: 'Any' - ) - data.splice 1, 0, 'divider' - - callback(data) - id: (obj) -> - obj.id - text: (obj) -> - obj.name - toggleLabel: (obj) -> - "#{$groupDropdown.data('default-label')} #{obj.name}" - clicked: => - @submitSearch() - ) - - $projectDropdown.glDropdown( - selectable: true - filterable: true - fieldName: 'project_id' - data: (term, callback) -> - Api.projects term, 'id', (data) -> - data.unshift( - name_with_namespace: 'Any' - ) - data.splice 1, 0, 'divider' - - callback(data) - id: (obj) -> - obj.id - text: (obj) -> - obj.name_with_namespace - toggleLabel: (obj) -> - "#{$projectDropdown.data('default-label')} #{obj.name_with_namespace}" - clicked: => - @submitSearch() - ) - - eventListeners: -> - $(document) - .off 'keyup', '.js-search-input' - .on 'keyup', '.js-search-input', @searchKeyUp - - $(document) - .off 'click', '.js-search-clear' - .on 'click', '.js-search-clear', @clearSearchField - - submitSearch: -> - $('.js-search-form').submit() - - searchKeyUp: -> - $input = $(@) - - if $input.val() is '' - $('.js-search-clear').addClass 'hidden' - else - $('.js-search-clear').removeClass 'hidden' - - clearSearchField: -> - $('.js-search-input') - .val '' - .trigger 'keyup' - .focus() diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js new file mode 100644 index 00000000000..990f6536eb2 --- /dev/null +++ b/app/assets/javascripts/search_autocomplete.js @@ -0,0 +1,360 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.SearchAutocomplete = (function() { + var KEYCODE; + + KEYCODE = { + ESCAPE: 27, + BACKSPACE: 8, + ENTER: 13 + }; + + function SearchAutocomplete(opts) { + var ref, ref1, ref2, ref3, ref4; + if (opts == null) { + opts = {}; + } + this.onSearchInputBlur = bind(this.onSearchInputBlur, this); + this.onClearInputClick = bind(this.onClearInputClick, this); + this.onSearchInputFocus = bind(this.onSearchInputFocus, this); + this.onSearchInputClick = bind(this.onSearchInputClick, this); + this.onSearchInputKeyUp = bind(this.onSearchInputKeyUp, this); + this.onSearchInputKeyDown = bind(this.onSearchInputKeyDown, this); + this.wrap = (ref = opts.wrap) != null ? ref : $('.search'), this.optsEl = (ref1 = opts.optsEl) != null ? ref1 : this.wrap.find('.search-autocomplete-opts'), this.autocompletePath = (ref2 = opts.autocompletePath) != null ? ref2 : this.optsEl.data('autocomplete-path'), this.projectId = (ref3 = opts.projectId) != null ? ref3 : this.optsEl.data('autocomplete-project-id') || '', this.projectRef = (ref4 = opts.projectRef) != null ? ref4 : this.optsEl.data('autocomplete-project-ref') || ''; + this.dropdown = this.wrap.find('.dropdown'); + this.dropdownContent = this.dropdown.find('.dropdown-content'); + this.locationBadgeEl = this.getElement('.location-badge'); + this.scopeInputEl = this.getElement('#scope'); + this.searchInput = this.getElement('.search-input'); + this.projectInputEl = this.getElement('#search_project_id'); + this.groupInputEl = this.getElement('#group_id'); + this.searchCodeInputEl = this.getElement('#search_code'); + this.repositoryInputEl = this.getElement('#repository_ref'); + this.clearInput = this.getElement('.js-clear-input'); + this.saveOriginalState(); + if (gon.current_user_id) { + this.createAutocomplete(); + } + this.searchInput.addClass('disabled'); + this.saveTextLength(); + this.bindEvents(); + } + + SearchAutocomplete.prototype.getElement = function(selector) { + return this.wrap.find(selector); + }; + + SearchAutocomplete.prototype.saveOriginalState = function() { + return this.originalState = this.serializeState(); + }; + + SearchAutocomplete.prototype.saveTextLength = function() { + return this.lastTextLength = this.searchInput.val().length; + }; + + SearchAutocomplete.prototype.createAutocomplete = function() { + return this.searchInput.glDropdown({ + filterInputBlur: false, + filterable: true, + filterRemote: true, + highlight: true, + enterCallback: false, + filterInput: 'input#search', + search: { + fields: ['text'] + }, + data: this.getData.bind(this), + selectable: true, + clicked: this.onClick.bind(this) + }); + }; + + SearchAutocomplete.prototype.getData = function(term, callback) { + var _this, contents, jqXHR; + _this = this; + if (!term) { + if (contents = this.getCategoryContents()) { + this.searchInput.data('glDropdown').filter.options.callback(contents); + this.enableAutocomplete(); + } + return; + } + if (this.loadingSuggestions) { + return; + } + this.loadingSuggestions = true; + return jqXHR = $.get(this.autocompletePath, { + project_id: this.projectId, + project_ref: this.projectRef, + term: term + }, function(response) { + var data, firstCategory, i, lastCategory, len, suggestion; + if (!response.length) { + _this.disableAutocomplete(); + return; + } + data = []; + firstCategory = true; + for (i = 0, len = response.length; i < len; i++) { + suggestion = response[i]; + if (lastCategory !== suggestion.category) { + if (!firstCategory) { + data.push('separator'); + } + if (firstCategory) { + firstCategory = false; + } + data.push({ + header: suggestion.category + }); + lastCategory = suggestion.category; + } + data.push({ + id: (suggestion.category.toLowerCase()) + "-" + suggestion.id, + category: suggestion.category, + text: suggestion.label, + url: suggestion.url + }); + } + if (data.length) { + data.push('separator'); + data.push({ + text: "Result name contains \"" + term + "\"", + url: "/search?search=" + term + "&project_id=" + (_this.projectInputEl.val()) + "&group_id=" + (_this.groupInputEl.val()) + }); + } + return callback(data); + }).always(function() { + return _this.loadingSuggestions = false; + }); + }; + + SearchAutocomplete.prototype.getCategoryContents = function() { + var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, utils; + userId = gon.current_user_id; + utils = gl.utils, projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions; + if (utils.isInGroupsPage() && groupOptions) { + options = groupOptions[utils.getGroupSlug()]; + } else if (utils.isInProjectPage() && projectOptions) { + options = projectOptions[utils.getProjectSlug()]; + } else if (dashboardOptions) { + options = dashboardOptions; + } + issuesPath = options.issuesPath, mrPath = options.mrPath, name = options.name; + items = [ + { + header: "" + name + }, { + text: 'Issues assigned to me', + url: issuesPath + "/?assignee_id=" + userId + }, { + text: "Issues I've created", + url: issuesPath + "/?author_id=" + userId + }, 'separator', { + text: 'Merge requests assigned to me', + url: mrPath + "/?assignee_id=" + userId + }, { + text: "Merge requests I've created", + url: mrPath + "/?author_id=" + userId + } + ]; + if (!name) { + items.splice(0, 1); + } + return items; + }; + + SearchAutocomplete.prototype.serializeState = function() { + return { + search_project_id: this.projectInputEl.val(), + group_id: this.groupInputEl.val(), + search_code: this.searchCodeInputEl.val(), + repository_ref: this.repositoryInputEl.val(), + scope: this.scopeInputEl.val(), + _location: this.locationBadgeEl.text() + }; + }; + + SearchAutocomplete.prototype.bindEvents = function() { + this.searchInput.on('keydown', this.onSearchInputKeyDown); + this.searchInput.on('keyup', this.onSearchInputKeyUp); + this.searchInput.on('click', this.onSearchInputClick); + this.searchInput.on('focus', this.onSearchInputFocus); + this.searchInput.on('blur', this.onSearchInputBlur); + this.clearInput.on('click', this.onClearInputClick); + return this.locationBadgeEl.on('click', (function(_this) { + return function() { + return _this.searchInput.focus(); + }; + })(this)); + }; + + SearchAutocomplete.prototype.enableAutocomplete = function() { + var _this; + if (!gon.current_user_id) { + return; + } + if (!this.dropdown.hasClass('open')) { + _this = this; + this.loadingSuggestions = false; + this.dropdown.addClass('open').trigger('shown.bs.dropdown'); + return this.searchInput.removeClass('disabled'); + } + }; + + SearchAutocomplete.prototype.onSearchInputKeyDown = function() { + return this.saveTextLength(); + }; + + SearchAutocomplete.prototype.onSearchInputKeyUp = function(e) { + switch (e.keyCode) { + case KEYCODE.BACKSPACE: + if (this.lastTextLength === 0 && this.badgePresent()) { + this.removeLocationBadge(); + } + if (this.lastTextLength === 1) { + this.disableAutocomplete(); + } + if (this.lastTextLength > 1) { + this.enableAutocomplete(); + } + break; + case KEYCODE.ESCAPE: + this.restoreOriginalState(); + break; + default: + if (this.searchInput.val() === '') { + this.disableAutocomplete(); + } else { + if (e.keyCode !== KEYCODE.ENTER) { + this.enableAutocomplete(); + } + } + } + this.wrap.toggleClass('has-value', !!e.target.value); + }; + + SearchAutocomplete.prototype.onSearchInputClick = function(e) { + return e.stopImmediatePropagation(); + }; + + SearchAutocomplete.prototype.onSearchInputFocus = function() { + this.isFocused = true; + this.wrap.addClass('search-active'); + if (this.getValue() === '') { + return this.getData(); + } + }; + + SearchAutocomplete.prototype.getValue = function() { + return this.searchInput.val(); + }; + + SearchAutocomplete.prototype.onClearInputClick = function(e) { + e.preventDefault(); + return this.searchInput.val('').focus(); + }; + + SearchAutocomplete.prototype.onSearchInputBlur = function(e) { + this.isFocused = false; + this.wrap.removeClass('search-active'); + if (this.searchInput.val() === '') { + return this.restoreOriginalState(); + } + }; + + SearchAutocomplete.prototype.addLocationBadge = function(item) { + var badgeText, category, value; + category = item.category != null ? item.category + ": " : ''; + value = item.value != null ? item.value : ''; + badgeText = "" + category + value; + this.locationBadgeEl.text(badgeText).show(); + return this.wrap.addClass('has-location-badge'); + }; + + SearchAutocomplete.prototype.hasLocationBadge = function() { + return this.wrap.is('.has-location-badge'); + }; + + SearchAutocomplete.prototype.restoreOriginalState = function() { + var i, input, inputs, len; + inputs = Object.keys(this.originalState); + for (i = 0, len = inputs.length; i < len; i++) { + input = inputs[i]; + this.getElement("#" + input).val(this.originalState[input]); + } + if (this.originalState._location === '') { + return this.locationBadgeEl.hide(); + } else { + return this.addLocationBadge({ + value: this.originalState._location + }); + } + }; + + SearchAutocomplete.prototype.badgePresent = function() { + return this.locationBadgeEl.length; + }; + + SearchAutocomplete.prototype.resetSearchState = function() { + var i, input, inputs, len, results; + inputs = Object.keys(this.originalState); + results = []; + for (i = 0, len = inputs.length; i < len; i++) { + input = inputs[i]; + if (input === '_location') { + break; + } + results.push(this.getElement("#" + input).val('')); + } + return results; + }; + + SearchAutocomplete.prototype.removeLocationBadge = function() { + this.locationBadgeEl.hide(); + this.resetSearchState(); + this.wrap.removeClass('has-location-badge'); + return this.disableAutocomplete(); + }; + + SearchAutocomplete.prototype.disableAutocomplete = function() { + this.searchInput.addClass('disabled'); + this.dropdown.removeClass('open'); + return this.restoreMenu(); + }; + + SearchAutocomplete.prototype.restoreMenu = function() { + var html; + html = "<ul> <li><a class='dropdown-menu-empty-link is-focused'>Loading...</a></li> </ul>"; + return this.dropdownContent.html(html); + }; + + SearchAutocomplete.prototype.onClick = function(item, $el, e) { + if (location.pathname.indexOf(item.url) !== -1) { + e.preventDefault(); + if (!this.badgePresent) { + if (item.category === 'Projects') { + this.projectInputEl.val(item.id); + this.addLocationBadge({ + value: 'This project' + }); + } + if (item.category === 'Groups') { + this.groupInputEl.val(item.id); + this.addLocationBadge({ + value: 'This group' + }); + } + } + $el.removeClass('is-active'); + this.disableAutocomplete(); + return this.searchInput.val('').focus(); + } + }; + + return SearchAutocomplete; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee deleted file mode 100644 index 72b1d3dfb1e..00000000000 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ /dev/null @@ -1,334 +0,0 @@ -class @SearchAutocomplete - - KEYCODE = - ESCAPE: 27 - BACKSPACE: 8 - ENTER: 13 - - constructor: (opts = {}) -> - { - @wrap = $('.search') - - @optsEl = @wrap.find('.search-autocomplete-opts') - @autocompletePath = @optsEl.data('autocomplete-path') - @projectId = @optsEl.data('autocomplete-project-id') || '' - @projectRef = @optsEl.data('autocomplete-project-ref') || '' - - } = opts - - # Dropdown Element - @dropdown = @wrap.find('.dropdown') - @dropdownContent = @dropdown.find('.dropdown-content') - - @locationBadgeEl = @getElement('.location-badge') - @scopeInputEl = @getElement('#scope') - @searchInput = @getElement('.search-input') - @projectInputEl = @getElement('#search_project_id') - @groupInputEl = @getElement('#group_id') - @searchCodeInputEl = @getElement('#search_code') - @repositoryInputEl = @getElement('#repository_ref') - @clearInput = @getElement('.js-clear-input') - - @saveOriginalState() - - # Only when user is logged in - @createAutocomplete() if gon.current_user_id - - @searchInput.addClass('disabled') - - @saveTextLength() - - @bindEvents() - - # Finds an element inside wrapper element - getElement: (selector) -> - @wrap.find(selector) - - saveOriginalState: -> - @originalState = @serializeState() - - saveTextLength: -> - @lastTextLength = @searchInput.val().length - - createAutocomplete: -> - @searchInput.glDropdown - filterInputBlur: false - filterable: true - filterRemote: true - highlight: true - enterCallback: false - filterInput: 'input#search' - search: - fields: ['text'] - data: @getData.bind(@) - selectable: true - clicked: @onClick.bind(@) - - getData: (term, callback) -> - _this = @ - - unless term - if contents = @getCategoryContents() - @searchInput.data('glDropdown').filter.options.callback contents - @enableAutocomplete() - - return - - # Prevent multiple ajax calls - return if @loadingSuggestions - - @loadingSuggestions = true - - jqXHR = $.get(@autocompletePath, { - project_id: @projectId - project_ref: @projectRef - term: term - }, (response) -> - # Hide dropdown menu if no suggestions returns - if !response.length - _this.disableAutocomplete() - return - - data = [] - - # List results - firstCategory = true - for suggestion in response - - # Add group header before list each group - if lastCategory isnt suggestion.category - data.push 'separator' if !firstCategory - - firstCategory = false if firstCategory - - data.push - header: suggestion.category - - lastCategory = suggestion.category - - data.push - id: "#{suggestion.category.toLowerCase()}-#{suggestion.id}" - category: suggestion.category - text: suggestion.label - url: suggestion.url - - # Add option to proceed with the search - if data.length - data.push('separator') - data.push - text: "Result name contains \"#{term}\"" - url: "/search?\ - search=#{term}\ - &project_id=#{_this.projectInputEl.val()}\ - &group_id=#{_this.groupInputEl.val()}" - - callback(data) - ).always -> - _this.loadingSuggestions = false - - - getCategoryContents: -> - - userId = gon.current_user_id - { utils, projectOptions, groupOptions, dashboardOptions } = gl - - if utils.isInGroupsPage() and groupOptions - options = groupOptions[utils.getGroupSlug()] - - else if utils.isInProjectPage() and projectOptions - options = projectOptions[utils.getProjectSlug()] - - else if dashboardOptions - options = dashboardOptions - - { issuesPath, mrPath, name } = options - - items = [ - { header: "#{name}" } - { text: 'Issues assigned to me', url: "#{issuesPath}/?assignee_id=#{userId}" } - { text: "Issues I've created", url: "#{issuesPath}/?author_id=#{userId}" } - 'separator' - { text: 'Merge requests assigned to me', url: "#{mrPath}/?assignee_id=#{userId}" } - { text: "Merge requests I've created", url: "#{mrPath}/?author_id=#{userId}" } - ] - - items.splice 0, 1 unless name - - return items - - - serializeState: -> - { - # Search Criteria - search_project_id: @projectInputEl.val() - group_id: @groupInputEl.val() - search_code: @searchCodeInputEl.val() - repository_ref: @repositoryInputEl.val() - scope: @scopeInputEl.val() - - # Location badge - _location: @locationBadgeEl.text() - } - - bindEvents: -> - @searchInput.on 'keydown', @onSearchInputKeyDown - @searchInput.on 'keyup', @onSearchInputKeyUp - @searchInput.on 'click', @onSearchInputClick - @searchInput.on 'focus', @onSearchInputFocus - @searchInput.on 'blur', @onSearchInputBlur - @clearInput.on 'click', @onClearInputClick - @locationBadgeEl.on 'click', => - @searchInput.focus() - - enableAutocomplete: -> - # No need to enable anything if user is not logged in - return if !gon.current_user_id - - unless @dropdown.hasClass('open') - _this = @ - @loadingSuggestions = false - - @dropdown - .addClass('open') - .trigger('shown.bs.dropdown') - @searchInput.removeClass('disabled') - - onSearchInputKeyDown: => - # Saves last length of the entered text - @saveTextLength() - - onSearchInputKeyUp: (e) => - switch e.keyCode - when KEYCODE.BACKSPACE - # when trying to remove the location badge - if @lastTextLength is 0 and @badgePresent() - @removeLocationBadge() - - # When removing the last character and no badge is present - if @lastTextLength is 1 - @disableAutocomplete() - - # When removing any character from existin value - if @lastTextLength > 1 - @enableAutocomplete() - - when KEYCODE.ESCAPE - @restoreOriginalState() - - else - # Handle the case when deleting the input value other than backspace - # e.g. Pressing ctrl + backspace or ctrl + x - if @searchInput.val() is '' - @disableAutocomplete() - else - # We should display the menu only when input is not empty - @enableAutocomplete() if e.keyCode isnt KEYCODE.ENTER - - @wrap.toggleClass 'has-value', !!e.target.value - - # Avoid falsy value to be returned - return - - onSearchInputClick: (e) => - # Prevents closing the dropdown menu - e.stopImmediatePropagation() - - onSearchInputFocus: => - @isFocused = true - @wrap.addClass('search-active') - - @getData() if @getValue() is '' - - - getValue: -> return @searchInput.val() - - - onClearInputClick: (e) => - e.preventDefault() - @searchInput.val('').focus() - - onSearchInputBlur: (e) => - @isFocused = false - @wrap.removeClass('search-active') - - # If input is blank then restore state - if @searchInput.val() is '' - @restoreOriginalState() - - addLocationBadge: (item) -> - category = if item.category? then "#{item.category}: " else '' - value = if item.value? then item.value else '' - - badgeText = "#{category}#{value}" - @locationBadgeEl.text(badgeText).show() - @wrap.addClass('has-location-badge') - - - hasLocationBadge: -> return @wrap.is '.has-location-badge' - - - restoreOriginalState: -> - inputs = Object.keys @originalState - - for input in inputs - @getElement("##{input}").val(@originalState[input]) - - if @originalState._location is '' - @locationBadgeEl.hide() - else - @addLocationBadge( - value: @originalState._location - ) - - badgePresent: -> - @locationBadgeEl.length - - resetSearchState: -> - inputs = Object.keys @originalState - - for input in inputs - - # _location isnt a input - break if input is '_location' - - @getElement("##{input}").val('') - - - removeLocationBadge: -> - - @locationBadgeEl.hide() - @resetSearchState() - @wrap.removeClass('has-location-badge') - @disableAutocomplete() - - - disableAutocomplete: -> - @searchInput.addClass('disabled') - @dropdown.removeClass('open') - @restoreMenu() - - restoreMenu: -> - html = "<ul> - <li><a class='dropdown-menu-empty-link is-focused'>Loading...</a></li> - </ul>" - @dropdownContent.html(html) - - onClick: (item, $el, e) -> - if location.pathname.indexOf(item.url) isnt -1 - e.preventDefault() - if not @badgePresent - if item.category is 'Projects' - @projectInputEl.val(item.id) - @addLocationBadge( - value: 'This project' - ) - - if item.category is 'Groups' - @groupInputEl.val(item.id) - @addLocationBadge( - value: 'This group' - ) - - $el.removeClass('is-active') - @disableAutocomplete() - @searchInput.val('').focus() diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js new file mode 100644 index 00000000000..3b28332854a --- /dev/null +++ b/app/assets/javascripts/shortcuts.js @@ -0,0 +1,97 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Shortcuts = (function() { + function Shortcuts(skipResetBindings) { + this.onToggleHelp = bind(this.onToggleHelp, this); + this.enabledHelp = []; + if (!skipResetBindings) { + Mousetrap.reset(); + } + Mousetrap.bind('?', this.onToggleHelp); + Mousetrap.bind('s', Shortcuts.focusSearch); + Mousetrap.bind('f', (function(_this) { + return function(e) { + return _this.focusFilter(e); + }; + })(this)); + Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], this.toggleMarkdownPreview); + if (typeof findFileURL !== "undefined" && findFileURL !== null) { + Mousetrap.bind('t', function() { + return Turbolinks.visit(findFileURL); + }); + } + } + + Shortcuts.prototype.onToggleHelp = function(e) { + e.preventDefault(); + return Shortcuts.toggleHelp(this.enabledHelp); + }; + + Shortcuts.prototype.toggleMarkdownPreview = function(e) { + return $(document).triggerHandler('markdown-preview:toggle', [e]); + }; + + Shortcuts.toggleHelp = function(location) { + var $modal; + $modal = $('#modal-shortcuts'); + if ($modal.length) { + $modal.modal('toggle'); + return; + } + return $.ajax({ + url: gon.shortcuts_path, + dataType: 'script', + success: function(e) { + var i, l, len, results; + if (location && location.length > 0) { + results = []; + for (i = 0, len = location.length; i < len; i++) { + l = location[i]; + results.push($(l).show()); + } + return results; + } else { + $('.hidden-shortcut').show(); + return $('.js-more-help-button').remove(); + } + } + }); + }; + + Shortcuts.prototype.focusFilter = function(e) { + if (this.filterInput == null) { + this.filterInput = $('input[type=search]', '.nav-controls'); + } + this.filterInput.focus(); + return e.preventDefault(); + }; + + Shortcuts.focusSearch = function(e) { + $('#search').focus(); + return e.preventDefault(); + }; + + return Shortcuts; + + })(); + + $(document).on('click.more_help', '.js-more-help-button', function(e) { + $(this).remove(); + $('.hidden-shortcut').show(); + return e.preventDefault(); + }); + + Mousetrap.stopCallback = (function() { + var defaultStopCallback; + defaultStopCallback = Mousetrap.stopCallback; + return function(e, element, combo) { + if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) { + return false; + } else { + return defaultStopCallback.apply(this, arguments); + } + }; + })(); + +}).call(this); diff --git a/app/assets/javascripts/shortcuts.js.coffee b/app/assets/javascripts/shortcuts.js.coffee deleted file mode 100644 index 8c8689bacee..00000000000 --- a/app/assets/javascripts/shortcuts.js.coffee +++ /dev/null @@ -1,60 +0,0 @@ -class @Shortcuts - constructor: (skipResetBindings) -> - @enabledHelp = [] - Mousetrap.reset() if not skipResetBindings - Mousetrap.bind '?', @onToggleHelp - Mousetrap.bind 's', Shortcuts.focusSearch - Mousetrap.bind 'f', (e) => @focusFilter e - Mousetrap.bind ['ctrl+shift+p', 'command+shift+p'], @toggleMarkdownPreview - Mousetrap.bind('t', -> Turbolinks.visit(findFileURL)) if findFileURL? - - onToggleHelp: (e) => - e.preventDefault() - Shortcuts.toggleHelp(@enabledHelp) - - toggleMarkdownPreview: (e) -> - $(document).triggerHandler('markdown-preview:toggle', [e]) - - @toggleHelp: (location) -> - $modal = $('#modal-shortcuts') - - if $modal.length - $modal.modal('toggle') - return - - $.ajax( - url: gon.shortcuts_path, - dataType: 'script', - success: (e) -> - if location and location.length > 0 - $(l).show() for l in location - else - $('.hidden-shortcut').show() - $('.js-more-help-button').remove() - ) - - focusFilter: (e) -> - @filterInput ?= $('input[type=search]', '.nav-controls') - @filterInput.focus() - e.preventDefault() - - @focusSearch: (e) -> - $('#search').focus() - e.preventDefault() - - -$(document).on 'click.more_help', '.js-more-help-button', (e) -> - $(@).remove() - $('.hidden-shortcut').show() - e.preventDefault() - -Mousetrap.stopCallback = (-> - defaultStopCallback = Mousetrap.stopCallback - - return (e, element, combo) -> - # allowed shortcuts if textarea, input, contenteditable are focused - if ['ctrl+shift+p', 'command+shift+p'].indexOf(combo) != -1 - return false - else - return defaultStopCallback.apply(@, arguments) -)() diff --git a/app/assets/javascripts/shortcuts_blob.coffee b/app/assets/javascripts/shortcuts_blob.coffee deleted file mode 100644 index 6d21e5d1150..00000000000 --- a/app/assets/javascripts/shortcuts_blob.coffee +++ /dev/null @@ -1,10 +0,0 @@ -#= require shortcuts - -class @ShortcutsBlob extends Shortcuts - constructor: (skipResetBindings) -> - super skipResetBindings - Mousetrap.bind('y', ShortcutsBlob.copyToClipboard) - - @copyToClipboard: -> - clipboardButton = $('.btn-clipboard') - clipboardButton.click() if clipboardButton diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js new file mode 100644 index 00000000000..b931eab638f --- /dev/null +++ b/app/assets/javascripts/shortcuts_blob.js @@ -0,0 +1,28 @@ + +/*= require shortcuts */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.ShortcutsBlob = (function(superClass) { + extend(ShortcutsBlob, superClass); + + function ShortcutsBlob(skipResetBindings) { + ShortcutsBlob.__super__.constructor.call(this, skipResetBindings); + Mousetrap.bind('y', ShortcutsBlob.copyToClipboard); + } + + ShortcutsBlob.copyToClipboard = function() { + var clipboardButton; + clipboardButton = $('.btn-clipboard'); + if (clipboardButton) { + return clipboardButton.click(); + } + }; + + return ShortcutsBlob; + + })(Shortcuts); + +}).call(this); diff --git a/app/assets/javascripts/shortcuts_dashboard_navigation.js b/app/assets/javascripts/shortcuts_dashboard_navigation.js new file mode 100644 index 00000000000..f7492a2aa5c --- /dev/null +++ b/app/assets/javascripts/shortcuts_dashboard_navigation.js @@ -0,0 +1,39 @@ + +/*= require shortcuts */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.ShortcutsDashboardNavigation = (function(superClass) { + extend(ShortcutsDashboardNavigation, superClass); + + function ShortcutsDashboardNavigation() { + ShortcutsDashboardNavigation.__super__.constructor.call(this); + Mousetrap.bind('g a', function() { + return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-activity'); + }); + Mousetrap.bind('g i', function() { + return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-issues'); + }); + Mousetrap.bind('g m', function() { + return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-merge_requests'); + }); + Mousetrap.bind('g p', function() { + return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-projects'); + }); + } + + ShortcutsDashboardNavigation.findAndFollowLink = function(selector) { + var link; + link = $(selector).attr('href'); + if (link) { + return window.location = link; + } + }; + + return ShortcutsDashboardNavigation; + + })(Shortcuts); + +}).call(this); diff --git a/app/assets/javascripts/shortcuts_dashboard_navigation.js.coffee b/app/assets/javascripts/shortcuts_dashboard_navigation.js.coffee deleted file mode 100644 index cca2b8a1fcc..00000000000 --- a/app/assets/javascripts/shortcuts_dashboard_navigation.js.coffee +++ /dev/null @@ -1,14 +0,0 @@ -#= require shortcuts - -class @ShortcutsDashboardNavigation extends Shortcuts - constructor: -> - super() - Mousetrap.bind('g a', -> ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-activity')) - Mousetrap.bind('g i', -> ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-issues')) - Mousetrap.bind('g m', -> ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-merge_requests')) - Mousetrap.bind('g p', -> ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-projects')) - - @findAndFollowLink: (selector) -> - link = $(selector).attr('href') - if link - window.location = link diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js new file mode 100644 index 00000000000..6c78914d338 --- /dev/null +++ b/app/assets/javascripts/shortcuts_find_file.js @@ -0,0 +1,35 @@ + +/*= require shortcuts_navigation */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.ShortcutsFindFile = (function(superClass) { + extend(ShortcutsFindFile, superClass); + + function ShortcutsFindFile(projectFindFile) { + var _oldStopCallback; + this.projectFindFile = projectFindFile; + ShortcutsFindFile.__super__.constructor.call(this); + _oldStopCallback = Mousetrap.stopCallback; + Mousetrap.stopCallback = (function(_this) { + return function(event, element, combo) { + if (element === _this.projectFindFile.inputElement[0] && (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter')) { + event.preventDefault(); + return false; + } + return _oldStopCallback(event, element, combo); + }; + })(this); + Mousetrap.bind('up', this.projectFindFile.selectRowUp); + Mousetrap.bind('down', this.projectFindFile.selectRowDown); + Mousetrap.bind('esc', this.projectFindFile.goToTree); + Mousetrap.bind('enter', this.projectFindFile.goToBlob); + } + + return ShortcutsFindFile; + + })(ShortcutsNavigation); + +}).call(this); diff --git a/app/assets/javascripts/shortcuts_find_file.js.coffee b/app/assets/javascripts/shortcuts_find_file.js.coffee deleted file mode 100644 index 311e80bae19..00000000000 --- a/app/assets/javascripts/shortcuts_find_file.js.coffee +++ /dev/null @@ -1,19 +0,0 @@ -#= require shortcuts_navigation - -class @ShortcutsFindFile extends ShortcutsNavigation - constructor: (@projectFindFile) -> - super() - _oldStopCallback = Mousetrap.stopCallback - # override to fire shortcuts action when focus in textbox - Mousetrap.stopCallback = (event, element, combo) => - if element == @projectFindFile.inputElement[0] and (combo == 'up' or combo == 'down' or combo == 'esc' or combo == 'enter') - # when press up/down key in textbox, cusor prevent to move to home/end - event.preventDefault() - return false - - return _oldStopCallback(event, element, combo) - - Mousetrap.bind('up', @projectFindFile.selectRowUp) - Mousetrap.bind('down', @projectFindFile.selectRowDown) - Mousetrap.bind('esc', @projectFindFile.goToTree) - Mousetrap.bind('enter', @projectFindFile.goToBlob) diff --git a/app/assets/javascripts/shortcuts_issuable.coffee b/app/assets/javascripts/shortcuts_issuable.coffee deleted file mode 100644 index c93bcf3ceec..00000000000 --- a/app/assets/javascripts/shortcuts_issuable.coffee +++ /dev/null @@ -1,53 +0,0 @@ -#= require mousetrap -#= require shortcuts_navigation - -class @ShortcutsIssuable extends ShortcutsNavigation - constructor: (isMergeRequest) -> - super() - Mousetrap.bind('a', @openSidebarDropdown.bind(@, 'assignee')) - Mousetrap.bind('m', @openSidebarDropdown.bind(@, 'milestone')) - Mousetrap.bind('r', => - @replyWithSelectedText() - return false - ) - Mousetrap.bind('e', => - @editIssue() - return false - ) - Mousetrap.bind('l', @openSidebarDropdown.bind(@, 'labels')) - - if isMergeRequest - @enabledHelp.push('.hidden-shortcut.merge_requests') - else - @enabledHelp.push('.hidden-shortcut.issues') - - replyWithSelectedText: -> - if window.getSelection - selected = window.getSelection().toString() - replyField = $('.js-main-target-form #note_note') - - return if selected.trim() == "" - - # Put a '>' character before each non-empty line in the selection - quote = _.map selected.split("\n"), (val) -> - "> #{val}\n" if val.trim() != '' - - # If replyField already has some content, add a newline before our quote - separator = replyField.val().trim() != "" and "\n" or '' - - replyField.val (_, current) -> - current + separator + quote.join('') + "\n" - - # Trigger autosave for the added text - replyField.trigger('input') - - # Focus the input field - replyField.focus() - - editIssue: -> - $editBtn = $('.issuable-edit') - Turbolinks.visit($editBtn.attr('href')) - - openSidebarDropdown: (name) -> - sidebar.openDropdown(name) - return false diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js new file mode 100644 index 00000000000..3f3a8a9dfd9 --- /dev/null +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -0,0 +1,75 @@ + +/*= require mousetrap */ + + +/*= require shortcuts_navigation */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.ShortcutsIssuable = (function(superClass) { + extend(ShortcutsIssuable, superClass); + + function ShortcutsIssuable(isMergeRequest) { + ShortcutsIssuable.__super__.constructor.call(this); + Mousetrap.bind('a', this.openSidebarDropdown.bind(this, 'assignee')); + Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone')); + Mousetrap.bind('r', (function(_this) { + return function() { + _this.replyWithSelectedText(); + return false; + }; + })(this)); + Mousetrap.bind('e', (function(_this) { + return function() { + _this.editIssue(); + return false; + }; + })(this)); + Mousetrap.bind('l', this.openSidebarDropdown.bind(this, 'labels')); + if (isMergeRequest) { + this.enabledHelp.push('.hidden-shortcut.merge_requests'); + } else { + this.enabledHelp.push('.hidden-shortcut.issues'); + } + } + + ShortcutsIssuable.prototype.replyWithSelectedText = function() { + var quote, replyField, selected, separator; + if (window.getSelection) { + selected = window.getSelection().toString(); + replyField = $('.js-main-target-form #note_note'); + if (selected.trim() === "") { + return; + } + quote = _.map(selected.split("\n"), function(val) { + if (val.trim() !== '') { + return "> " + val + "\n"; + } + }); + separator = replyField.val().trim() !== "" && "\n" || ''; + replyField.val(function(_, current) { + return current + separator + quote.join('') + "\n"; + }); + replyField.trigger('input'); + return replyField.focus(); + } + }; + + ShortcutsIssuable.prototype.editIssue = function() { + var $editBtn; + $editBtn = $('.issuable-edit'); + return Turbolinks.visit($editBtn.attr('href')); + }; + + ShortcutsIssuable.prototype.openSidebarDropdown = function(name) { + sidebar.openDropdown(name); + return false; + }; + + return ShortcutsIssuable; + + })(ShortcutsNavigation); + +}).call(this); diff --git a/app/assets/javascripts/shortcuts_navigation.coffee b/app/assets/javascripts/shortcuts_navigation.coffee deleted file mode 100644 index f39504e0645..00000000000 --- a/app/assets/javascripts/shortcuts_navigation.coffee +++ /dev/null @@ -1,23 +0,0 @@ -#= require shortcuts - -class @ShortcutsNavigation extends Shortcuts - constructor: -> - super() - Mousetrap.bind('g p', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-project')) - Mousetrap.bind('g e', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-project-activity')) - Mousetrap.bind('g f', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-tree')) - Mousetrap.bind('g c', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-commits')) - Mousetrap.bind('g b', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-builds')) - Mousetrap.bind('g n', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-network')) - Mousetrap.bind('g g', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-graphs')) - Mousetrap.bind('g i', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-issues')) - Mousetrap.bind('g m', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-merge_requests')) - Mousetrap.bind('g w', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-wiki')) - Mousetrap.bind('g s', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-snippets')) - Mousetrap.bind('i', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-new-issue')) - @enabledHelp.push('.hidden-shortcut.project') - - @findAndFollowLink: (selector) -> - link = $(selector).attr('href') - if link - window.location = link diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js new file mode 100644 index 00000000000..469e25482bb --- /dev/null +++ b/app/assets/javascripts/shortcuts_navigation.js @@ -0,0 +1,64 @@ + +/*= require shortcuts */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.ShortcutsNavigation = (function(superClass) { + extend(ShortcutsNavigation, superClass); + + function ShortcutsNavigation() { + ShortcutsNavigation.__super__.constructor.call(this); + Mousetrap.bind('g p', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-project'); + }); + Mousetrap.bind('g e', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-project-activity'); + }); + Mousetrap.bind('g f', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-tree'); + }); + Mousetrap.bind('g c', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-commits'); + }); + Mousetrap.bind('g b', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-builds'); + }); + Mousetrap.bind('g n', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-network'); + }); + Mousetrap.bind('g g', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-graphs'); + }); + Mousetrap.bind('g i', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-issues'); + }); + Mousetrap.bind('g m', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-merge_requests'); + }); + Mousetrap.bind('g w', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-wiki'); + }); + Mousetrap.bind('g s', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-snippets'); + }); + Mousetrap.bind('i', function() { + return ShortcutsNavigation.findAndFollowLink('.shortcuts-new-issue'); + }); + this.enabledHelp.push('.hidden-shortcut.project'); + } + + ShortcutsNavigation.findAndFollowLink = function(selector) { + var link; + link = $(selector).attr('href'); + if (link) { + return window.location = link; + } + }; + + return ShortcutsNavigation; + + })(Shortcuts); + +}).call(this); diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js new file mode 100644 index 00000000000..fb2b39e757e --- /dev/null +++ b/app/assets/javascripts/shortcuts_network.js @@ -0,0 +1,27 @@ + +/*= require shortcuts_navigation */ + +(function() { + var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, + hasProp = {}.hasOwnProperty; + + this.ShortcutsNetwork = (function(superClass) { + extend(ShortcutsNetwork, superClass); + + function ShortcutsNetwork(graph) { + this.graph = graph; + ShortcutsNetwork.__super__.constructor.call(this); + Mousetrap.bind(['left', 'h'], this.graph.scrollLeft); + Mousetrap.bind(['right', 'l'], this.graph.scrollRight); + Mousetrap.bind(['up', 'k'], this.graph.scrollUp); + Mousetrap.bind(['down', 'j'], this.graph.scrollDown); + Mousetrap.bind(['shift+up', 'shift+k'], this.graph.scrollTop); + Mousetrap.bind(['shift+down', 'shift+j'], this.graph.scrollBottom); + this.enabledHelp.push('.hidden-shortcut.network'); + } + + return ShortcutsNetwork; + + })(ShortcutsNavigation); + +}).call(this); diff --git a/app/assets/javascripts/shortcuts_network.js.coffee b/app/assets/javascripts/shortcuts_network.js.coffee deleted file mode 100644 index cc95ad7ebfe..00000000000 --- a/app/assets/javascripts/shortcuts_network.js.coffee +++ /dev/null @@ -1,12 +0,0 @@ -#= require shortcuts_navigation - -class @ShortcutsNetwork extends ShortcutsNavigation - constructor: (@graph) -> - super() - Mousetrap.bind(['left', 'h'], @graph.scrollLeft) - Mousetrap.bind(['right', 'l'], @graph.scrollRight) - Mousetrap.bind(['up', 'k'], @graph.scrollUp) - Mousetrap.bind(['down', 'j'], @graph.scrollDown) - Mousetrap.bind(['shift+up', 'shift+k'], @graph.scrollTop) - Mousetrap.bind(['shift+down', 'shift+j'], @graph.scrollBottom) - @enabledHelp.push('.hidden-shortcut.network') diff --git a/app/assets/javascripts/sidebar.js b/app/assets/javascripts/sidebar.js new file mode 100644 index 00000000000..bd0c1194b36 --- /dev/null +++ b/app/assets/javascripts/sidebar.js @@ -0,0 +1,41 @@ +(function() { + var collapsed, expanded, toggleSidebar; + + collapsed = 'page-sidebar-collapsed'; + + expanded = 'page-sidebar-expanded'; + + toggleSidebar = function() { + $('.page-with-sidebar').toggleClass(collapsed + " " + expanded); + $('.navbar-fixed-top').toggleClass("header-collapsed header-expanded"); + if ($.cookie('pin_nav') === 'true') { + $('.navbar-fixed-top').toggleClass('header-pinned-nav'); + $('.page-with-sidebar').toggleClass('page-sidebar-pinned'); + } + return setTimeout((function() { + var niceScrollBars; + niceScrollBars = $('.nav-sidebar').niceScroll(); + return niceScrollBars.updateScrollBar(); + }), 300); + }; + + $(document).off('click', 'body').on('click', 'body', function(e) { + var $nav, $target, $toggle, pageExpanded; + if ($.cookie('pin_nav') !== 'true') { + $target = $(e.target); + $nav = $target.closest('.sidebar-wrapper'); + pageExpanded = $('.page-with-sidebar').hasClass('page-sidebar-expanded'); + $toggle = $target.closest('.toggle-nav-collapse, .side-nav-toggle'); + if ($nav.length === 0 && pageExpanded && $toggle.length === 0) { + $('.page-with-sidebar').toggleClass('page-sidebar-collapsed page-sidebar-expanded'); + return $('.navbar-fixed-top').toggleClass('header-collapsed header-expanded'); + } + } + }); + + $(document).on("click", '.toggle-nav-collapse, .side-nav-toggle', function(e) { + e.preventDefault(); + return toggleSidebar(); + }); + +}).call(this); diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee deleted file mode 100644 index 68009e58645..00000000000 --- a/app/assets/javascripts/sidebar.js.coffee +++ /dev/null @@ -1,37 +0,0 @@ -collapsed = 'page-sidebar-collapsed' -expanded = 'page-sidebar-expanded' - -toggleSidebar = -> - $('.page-with-sidebar').toggleClass("#{collapsed} #{expanded}") - $('.navbar-fixed-top').toggleClass("header-collapsed header-expanded") - - if $.cookie('pin_nav') is 'true' - $('.navbar-fixed-top').toggleClass('header-pinned-nav') - $('.page-with-sidebar').toggleClass('page-sidebar-pinned') - - setTimeout ( -> - niceScrollBars = $('.nav-sidebar').niceScroll(); - niceScrollBars.updateScrollBar(); - ), 300 - -$(document) - .off 'click', 'body' - .on 'click', 'body', (e) -> - unless $.cookie('pin_nav') is 'true' - $target = $(e.target) - $nav = $target.closest('.sidebar-wrapper') - pageExpanded = $('.page-with-sidebar').hasClass('page-sidebar-expanded') - $toggle = $target.closest('.toggle-nav-collapse, .side-nav-toggle') - - if $nav.length is 0 and pageExpanded and $toggle.length is 0 - $('.page-with-sidebar') - .toggleClass('page-sidebar-collapsed page-sidebar-expanded') - - $('.navbar-fixed-top') - .toggleClass('header-collapsed header-expanded') - -$(document).on("click", '.toggle-nav-collapse, .side-nav-toggle', (e) -> - e.preventDefault() - - toggleSidebar() -) diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js new file mode 100644 index 00000000000..b9ae497b0e5 --- /dev/null +++ b/app/assets/javascripts/single_file_diff.js @@ -0,0 +1,77 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.SingleFileDiff = (function() { + var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER; + + WRAPPER = '<div class="diff-content diff-wrap-lines"></div>'; + + LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>'; + + ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>'; + + COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. Click to expand it.</div>'; + + function SingleFileDiff(file) { + this.file = file; + this.toggleDiff = bind(this.toggleDiff, this); + this.content = $('.diff-content', this.file); + this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path'); + this.isOpen = !this.diffForPath; + if (this.diffForPath) { + this.collapsedContent = this.content; + this.loadingContent = $(WRAPPER).addClass('loading').html(LOADING_HTML).hide(); + this.content = null; + this.collapsedContent.after(this.loadingContent); + } else { + this.collapsedContent = $(WRAPPER).html(COLLAPSED_HTML).hide(); + this.content.after(this.collapsedContent); + } + this.collapsedContent.on('click', this.toggleDiff); + $('.file-title > a', this.file).on('click', this.toggleDiff); + } + + SingleFileDiff.prototype.toggleDiff = function(e) { + this.isOpen = !this.isOpen; + if (!this.isOpen && !this.hasError) { + this.content.hide(); + return this.collapsedContent.show(); + } else if (this.content) { + this.collapsedContent.hide(); + return this.content.show(); + } else { + return this.getContentHTML(); + } + }; + + SingleFileDiff.prototype.getContentHTML = function() { + this.collapsedContent.hide(); + this.loadingContent.show(); + $.get(this.diffForPath, (function(_this) { + return function(data) { + _this.loadingContent.hide(); + if (data.html) { + _this.content = $(data.html); + _this.content.syntaxHighlight(); + } else { + _this.hasError = true; + _this.content = $(ERROR_HTML); + } + return _this.collapsedContent.after(_this.content); + }; + })(this)); + }; + + return SingleFileDiff; + + })(); + + $.fn.singleFileDiff = function() { + return this.each(function() { + if (!$.data(this, 'singleFileDiff')) { + return $.data(this, 'singleFileDiff', new SingleFileDiff(this)); + } + }); + }; + +}).call(this); diff --git a/app/assets/javascripts/single_file_diff.js.coffee b/app/assets/javascripts/single_file_diff.js.coffee deleted file mode 100644 index f3e225c3728..00000000000 --- a/app/assets/javascripts/single_file_diff.js.coffee +++ /dev/null @@ -1,54 +0,0 @@ -class @SingleFileDiff - - WRAPPER = '<div class="diff-content diff-wrap-lines"></div>' - LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>' - ERROR_HTML = '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>' - COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. Click to expand it.</div>' - - constructor: (@file) -> - @content = $('.diff-content', @file) - @diffForPath = @content.find('[data-diff-for-path]').data 'diff-for-path' - @isOpen = !@diffForPath - - if @diffForPath - @collapsedContent = @content - @loadingContent = $(WRAPPER).addClass('loading').html(LOADING_HTML).hide() - @content = null - @collapsedContent.after(@loadingContent) - else - @collapsedContent = $(WRAPPER).html(COLLAPSED_HTML).hide() - @content.after(@collapsedContent) - - @collapsedContent.on 'click', @toggleDiff - - $('.file-title > a', @file).on 'click', @toggleDiff - - toggleDiff: (e) => - @isOpen = !@isOpen - if not @isOpen and not @hasError - @content.hide() - @collapsedContent.show() - else if @content - @collapsedContent.hide() - @content.show() - else - @getContentHTML() - - getContentHTML: -> - @collapsedContent.hide() - @loadingContent.show() - $.get @diffForPath, (data) => - @loadingContent.hide() - if data.html - @content = $(data.html) - @content.syntaxHighlight() - else - @hasError = true - @content = $(ERROR_HTML) - @collapsedContent.after(@content) - return - -$.fn.singleFileDiff = -> - return @each -> - if not $.data this, 'singleFileDiff' - $.data this, 'singleFileDiff', new SingleFileDiff this diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js new file mode 100644 index 00000000000..10509313c12 --- /dev/null +++ b/app/assets/javascripts/star.js @@ -0,0 +1,31 @@ +(function() { + this.Star = (function() { + function Star() { + $('.project-home-panel .toggle-star').on('ajax:success', function(e, data, status, xhr) { + var $starIcon, $starSpan, $this, toggleStar; + $this = $(this); + $starSpan = $this.find('span'); + $starIcon = $this.find('i'); + toggleStar = function(isStarred) { + $this.parent().find('.star-count').text(data.star_count); + if (isStarred) { + $starSpan.removeClass('starred').text('Star'); + gl.utils.updateTooltipTitle($this, 'Star project'); + $starIcon.removeClass('fa-star').addClass('fa-star-o'); + } else { + $starSpan.addClass('starred').text('Unstar'); + gl.utils.updateTooltipTitle($this, 'Unstar project'); + $starIcon.removeClass('fa-star-o').addClass('fa-star'); + } + }; + toggleStar($starSpan.hasClass('starred')); + }).on('ajax:error', function(e, xhr, status, error) { + new Flash('Star toggle failed. Try again later.', 'alert'); + }); + } + + return Star; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/star.js.coffee b/app/assets/javascripts/star.js.coffee deleted file mode 100644 index 01b28171f72..00000000000 --- a/app/assets/javascripts/star.js.coffee +++ /dev/null @@ -1,24 +0,0 @@ -class @Star - constructor: -> - $('.project-home-panel .toggle-star').on('ajax:success', (e, data, status, xhr) -> - $this = $(this) - $starSpan = $this.find('span') - $starIcon = $this.find('i') - - toggleStar = (isStarred) -> - $this.parent().find('.star-count').text data.star_count - if isStarred - $starSpan.removeClass('starred').text 'Star' - gl.utils.updateTooltipTitle $this, 'Star project' - $starIcon.removeClass('fa-star').addClass 'fa-star-o' - else - $starSpan.addClass('starred').text 'Unstar' - gl.utils.updateTooltipTitle $this, 'Unstar project' - $starIcon.removeClass('fa-star-o').addClass 'fa-star' - return - - toggleStar $starSpan.hasClass('starred') - return - ).on 'ajax:error', (e, xhr, status, error) -> - new Flash('Star toggle failed. Try again later.', 'alert') - return diff --git a/app/assets/javascripts/subscription.js b/app/assets/javascripts/subscription.js new file mode 100644 index 00000000000..5e3c5983d75 --- /dev/null +++ b/app/assets/javascripts/subscription.js @@ -0,0 +1,41 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Subscription = (function() { + function Subscription(container) { + this.toggleSubscription = bind(this.toggleSubscription, this); + var $container; + $container = $(container); + this.url = $container.attr('data-url'); + this.subscribe_button = $container.find('.js-subscribe-button'); + this.subscription_status = $container.find('.subscription-status'); + this.subscribe_button.unbind('click').click(this.toggleSubscription); + } + + Subscription.prototype.toggleSubscription = function(event) { + var action, btn, current_status; + btn = $(event.currentTarget); + action = btn.find('span').text(); + current_status = this.subscription_status.attr('data-status'); + btn.addClass('disabled'); + return $.post(this.url, (function(_this) { + return function() { + var status; + btn.removeClass('disabled'); + status = current_status === 'subscribed' ? 'unsubscribed' : 'subscribed'; + _this.subscription_status.attr('data-status', status); + action = status === 'subscribed' ? 'Unsubscribe' : 'Subscribe'; + btn.find('span').text(action); + _this.subscription_status.find('>div').toggleClass('hidden'); + if (btn.attr('data-original-title')) { + return btn.tooltip('hide').attr('data-original-title', action).tooltip('fixTitle'); + } + }; + })(this)); + }; + + return Subscription; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/subscription.js.coffee b/app/assets/javascripts/subscription.js.coffee deleted file mode 100644 index 08d494aba9f..00000000000 --- a/app/assets/javascripts/subscription.js.coffee +++ /dev/null @@ -1,26 +0,0 @@ -class @Subscription - constructor: (container) -> - $container = $(container) - @url = $container.attr('data-url') - @subscribe_button = $container.find('.js-subscribe-button') - @subscription_status = $container.find('.subscription-status') - @subscribe_button.unbind('click').click(@toggleSubscription) - - toggleSubscription: (event) => - btn = $(event.currentTarget) - action = btn.find('span').text() - current_status = @subscription_status.attr('data-status') - btn.addClass('disabled') - - $.post @url, => - btn.removeClass('disabled') - status = if current_status == 'subscribed' then 'unsubscribed' else 'subscribed' - @subscription_status.attr('data-status', status) - action = if status == 'subscribed' then 'Unsubscribe' else 'Subscribe' - btn.find('span').text(action) - @subscription_status.find('>div').toggleClass('hidden') - - if btn.attr('data-original-title') - btn.tooltip('hide') - .attr('data-original-title', action) - .tooltip('fixTitle') diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js new file mode 100644 index 00000000000..d6c219603d1 --- /dev/null +++ b/app/assets/javascripts/subscription_select.js @@ -0,0 +1,35 @@ +(function() { + this.SubscriptionSelect = (function() { + function SubscriptionSelect() { + $('.js-subscription-event').each(function(i, el) { + var fieldName; + fieldName = $(el).data("field-name"); + return $(el).glDropdown({ + selectable: true, + fieldName: fieldName, + toggleLabel: (function(_this) { + return function(selected, el, instance) { + var $item, label; + label = 'Subscription'; + $item = instance.dropdown.find('.is-active'); + if ($item.length) { + label = $item.text(); + } + return label; + }; + })(this), + clicked: function(item, $el, e) { + return e.preventDefault(); + }, + id: function(obj, el) { + return $(el).data("id"); + } + }); + }); + } + + return SubscriptionSelect; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/subscription_select.js.coffee b/app/assets/javascripts/subscription_select.js.coffee deleted file mode 100644 index e5eb7a50d80..00000000000 --- a/app/assets/javascripts/subscription_select.js.coffee +++ /dev/null @@ -1,18 +0,0 @@ -class @SubscriptionSelect - constructor: -> - $('.js-subscription-event').each (i, el) -> - fieldName = $(el).data("field-name") - - $(el).glDropdown( - selectable: true - fieldName: fieldName - toggleLabel: (selected, el, instance) => - label = 'Subscription' - $item = instance.dropdown.find('.is-active') - label = $item.text() if $item.length - label - clicked: (item, $el, e)-> - e.preventDefault() - id: (obj, el) -> - $(el).data("id") - ) diff --git a/app/assets/javascripts/syntax_highlight.coffee b/app/assets/javascripts/syntax_highlight.coffee deleted file mode 100644 index 980f0232d10..00000000000 --- a/app/assets/javascripts/syntax_highlight.coffee +++ /dev/null @@ -1,20 +0,0 @@ -# Syntax Highlighter -# -# Applies a syntax highlighting color scheme CSS class to any element with the -# `js-syntax-highlight` class -# -# ### Example Markup -# -# <div class="js-syntax-highlight"></div> -# -$.fn.syntaxHighlight = -> - if $(this).hasClass('js-syntax-highlight') - # Given the element itself, apply highlighting - $(this).addClass(gon.user_color_scheme) - else - # Given a parent element, recurse to any of its applicable children - $children = $(this).find('.js-syntax-highlight') - $children.syntaxHighlight() if $children.length - -$(document).on 'ready page:load', -> - $('.js-syntax-highlight').syntaxHighlight() diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js new file mode 100644 index 00000000000..dba62638c78 --- /dev/null +++ b/app/assets/javascripts/syntax_highlight.js @@ -0,0 +1,18 @@ +(function() { + $.fn.syntaxHighlight = function() { + var $children; + if ($(this).hasClass('js-syntax-highlight')) { + return $(this).addClass(gon.user_color_scheme); + } else { + $children = $(this).find('.js-syntax-highlight'); + if ($children.length) { + return $children.syntaxHighlight(); + } + } + }; + + $(document).on('ready page:load', function() { + return $('.js-syntax-highlight').syntaxHighlight(); + }); + +}).call(this); diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js new file mode 100644 index 00000000000..6e677fa8cc6 --- /dev/null +++ b/app/assets/javascripts/todos.js @@ -0,0 +1,144 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Todos = (function() { + function Todos(opts) { + var ref; + if (opts == null) { + opts = {}; + } + this.allDoneClicked = bind(this.allDoneClicked, this); + this.doneClicked = bind(this.doneClicked, this); + this.el = (ref = opts.el) != null ? ref : $('.js-todos-options'); + this.perPage = this.el.data('perPage'); + this.clearListeners(); + this.initBtnListeners(); + } + + Todos.prototype.clearListeners = function() { + $('.done-todo').off('click'); + $('.js-todos-mark-all').off('click'); + return $('.todo').off('click'); + }; + + Todos.prototype.initBtnListeners = function() { + $('.done-todo').on('click', this.doneClicked); + $('.js-todos-mark-all').on('click', this.allDoneClicked); + return $('.todo').on('click', this.goToTodoUrl); + }; + + Todos.prototype.doneClicked = function(e) { + var $this; + e.preventDefault(); + e.stopImmediatePropagation(); + $this = $(e.currentTarget); + $this.disable(); + return $.ajax({ + type: 'POST', + url: $this.attr('href'), + dataType: 'json', + data: { + '_method': 'delete' + }, + success: (function(_this) { + return function(data) { + _this.redirectIfNeeded(data.count); + _this.clearDone($this.closest('li')); + return _this.updateBadges(data); + }; + })(this) + }); + }; + + Todos.prototype.allDoneClicked = function(e) { + var $this; + e.preventDefault(); + e.stopImmediatePropagation(); + $this = $(e.currentTarget); + $this.disable(); + return $.ajax({ + type: 'POST', + url: $this.attr('href'), + dataType: 'json', + data: { + '_method': 'delete' + }, + success: (function(_this) { + return function(data) { + $this.remove(); + $('.js-todos-list').remove(); + return _this.updateBadges(data); + }; + })(this) + }); + }; + + Todos.prototype.clearDone = function($row) { + var $ul; + $ul = $row.closest('ul'); + $row.remove(); + if (!$ul.find('li').length) { + return $ul.parents('.panel').remove(); + } + }; + + Todos.prototype.updateBadges = function(data) { + $('.todos-pending .badge, .todos-pending-count').text(data.count); + return $('.todos-done .badge').text(data.done_count); + }; + + Todos.prototype.getTotalPages = function() { + return this.el.data('totalPages'); + }; + + Todos.prototype.getCurrentPage = function() { + return this.el.data('currentPage'); + }; + + Todos.prototype.getTodosPerPage = function() { + return this.el.data('perPage'); + }; + + Todos.prototype.redirectIfNeeded = function(total) { + var currPage, currPages, newPages, pageParams, url; + currPages = this.getTotalPages(); + currPage = this.getCurrentPage(); + if (!total) { + location.reload(); + return; + } + if (!currPages) { + return; + } + newPages = Math.ceil(total / this.getTodosPerPage()); + url = location.href; + if (newPages !== currPages) { + if (currPages > 1 && currPage === currPages) { + pageParams = { + page: currPages - 1 + }; + url = gl.utils.mergeUrlParams(pageParams, url); + } + return Turbolinks.visit(url); + } + }; + + Todos.prototype.goToTodoUrl = function(e) { + var todoLink; + todoLink = $(this).data('url'); + if (!todoLink) { + return; + } + if (e.metaKey || e.which === 2) { + e.preventDefault(); + return window.open(todoLink, '_blank'); + } else { + return Turbolinks.visit(todoLink); + } + }; + + return Todos; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/todos.js.coffee b/app/assets/javascripts/todos.js.coffee deleted file mode 100644 index 10bef96f43d..00000000000 --- a/app/assets/javascripts/todos.js.coffee +++ /dev/null @@ -1,110 +0,0 @@ -class @Todos - constructor: (opts = {}) -> - { - @el = $('.js-todos-options') - } = opts - - @perPage = @el.data('perPage') - - @clearListeners() - @initBtnListeners() - - clearListeners: -> - $('.done-todo').off('click') - $('.js-todos-mark-all').off('click') - $('.todo').off('click') - - initBtnListeners: -> - $('.done-todo').on('click', @doneClicked) - $('.js-todos-mark-all').on('click', @allDoneClicked) - $('.todo').on('click', @goToTodoUrl) - - doneClicked: (e) => - e.preventDefault() - e.stopImmediatePropagation() - - $this = $(e.currentTarget) - $this.disable() - - $.ajax - type: 'POST' - url: $this.attr('href') - dataType: 'json' - data: '_method': 'delete' - success: (data) => - @redirectIfNeeded data.count - @clearDone $this.closest('li') - @updateBadges data - - allDoneClicked: (e) => - e.preventDefault() - e.stopImmediatePropagation() - - $this = $(e.currentTarget) - $this.disable() - - $.ajax - type: 'POST' - url: $this.attr('href') - dataType: 'json' - data: '_method': 'delete' - success: (data) => - $this.remove() - $('.js-todos-list').remove() - @updateBadges data - - clearDone: ($row) -> - $ul = $row.closest('ul') - $row.remove() - - if not $ul.find('li').length - $ul.parents('.panel').remove() - - updateBadges: (data) -> - $('.todos-pending .badge, .todos-pending-count').text data.count - $('.todos-done .badge').text data.done_count - - getTotalPages: -> - @el.data('totalPages') - - getCurrentPage: -> - @el.data('currentPage') - - getTodosPerPage: -> - @el.data('perPage') - - redirectIfNeeded: (total) -> - currPages = @getTotalPages() - currPage = @getCurrentPage() - - # Refresh if no remaining Todos - if not total - location.reload() - return - - # Do nothing if no pagination - return if not currPages - - newPages = Math.ceil(total / @getTodosPerPage()) - url = location.href # Includes query strings - - # If new total of pages is different than we have now - if newPages isnt currPages - # Redirect to previous page if there's one available - if currPages > 1 and currPage is currPages - pageParams = - page: currPages - 1 - url = gl.utils.mergeUrlParams(pageParams, url) - - Turbolinks.visit(url) - - goToTodoUrl: (e)-> - todoLink = $(this).data('url') - return unless todoLink - - # Allow Meta-Click or Mouse3-click to open in a new tab - if e.metaKey or e.which is 2 - e.preventDefault() - window.open(todoLink,'_blank') - else - Turbolinks.visit(todoLink) diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js new file mode 100644 index 00000000000..78e159a7ed9 --- /dev/null +++ b/app/assets/javascripts/tree.js @@ -0,0 +1,65 @@ +(function() { + this.TreeView = (function() { + function TreeView() { + this.initKeyNav(); + $(".tree-content-holder .tree-item").on('click', function(e) { + var $clickedEl, path; + $clickedEl = $(e.target); + path = $('.tree-item-file-name a', this).attr('href'); + if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) { + if (e.metaKey || e.which === 2) { + e.preventDefault(); + return window.open(path, '_blank'); + } else { + return Turbolinks.visit(path); + } + } + }); + $('span.log_loading:first').removeClass('hide'); + } + + TreeView.prototype.initKeyNav = function() { + var li, liSelected; + li = $("tr.tree-item"); + liSelected = null; + return $('body').keydown(function(e) { + var next, path; + if ($("input:focus").length > 0 && (e.which === 38 || e.which === 40)) { + return false; + } + if (e.which === 40) { + if (liSelected) { + next = liSelected.next(); + if (next.length > 0) { + liSelected.removeClass("selected"); + liSelected = next.addClass("selected"); + } + } else { + liSelected = li.eq(0).addClass("selected"); + } + return $(liSelected).focus(); + } else if (e.which === 38) { + if (liSelected) { + next = liSelected.prev(); + if (next.length > 0) { + liSelected.removeClass("selected"); + liSelected = next.addClass("selected"); + } + } else { + liSelected = li.last().addClass("selected"); + } + return $(liSelected).focus(); + } else if (e.which === 13) { + path = $('.tree-item.selected .tree-item-file-name a').attr('href'); + if (path) { + return Turbolinks.visit(path); + } + } + }); + }; + + return TreeView; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/tree.js.coffee b/app/assets/javascripts/tree.js.coffee deleted file mode 100644 index 83de584f2d9..00000000000 --- a/app/assets/javascripts/tree.js.coffee +++ /dev/null @@ -1,50 +0,0 @@ -class @TreeView - constructor: -> - @initKeyNav() - - # Code browser tree slider - # Make the entire tree-item row clickable, but not if clicking another link (like a commit message) - $(".tree-content-holder .tree-item").on 'click', (e) -> - $clickedEl = $(e.target) - path = $('.tree-item-file-name a', this).attr('href') - - if not $clickedEl.is('a') and not $clickedEl.is('.str-truncated') - if e.metaKey or e.which is 2 - e.preventDefault() - window.open path, '_blank' - else - Turbolinks.visit path - - # Show the "Loading commit data" for only the first element - $('span.log_loading:first').removeClass('hide') - - initKeyNav: -> - li = $("tr.tree-item") - liSelected = null - $('body').keydown (e) -> - if $("input:focus").length > 0 && (e.which == 38 || e.which == 40) - return false - - if e.which is 40 - if liSelected - next = liSelected.next() - if next.length > 0 - liSelected.removeClass "selected" - liSelected = next.addClass("selected") - else - liSelected = li.eq(0).addClass("selected") - - $(liSelected).focus() - else if e.which is 38 - if liSelected - next = liSelected.prev() - if next.length > 0 - liSelected.removeClass "selected" - liSelected = next.addClass("selected") - else - liSelected = li.last().addClass("selected") - - $(liSelected).focus() - else if e.which is 13 - path = $('.tree-item.selected .tree-item-file-name a').attr('href') - if path then Turbolinks.visit(path) diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js new file mode 100644 index 00000000000..9ba847fb0c2 --- /dev/null +++ b/app/assets/javascripts/u2f/authenticate.js @@ -0,0 +1,89 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.U2FAuthenticate = (function() { + function U2FAuthenticate(container, u2fParams) { + this.container = container; + this.renderNotSupported = bind(this.renderNotSupported, this); + this.renderAuthenticated = bind(this.renderAuthenticated, this); + this.renderError = bind(this.renderError, this); + this.renderInProgress = bind(this.renderInProgress, this); + this.renderSetup = bind(this.renderSetup, this); + this.renderTemplate = bind(this.renderTemplate, this); + this.authenticate = bind(this.authenticate, this); + this.start = bind(this.start, this); + this.appId = u2fParams.app_id; + this.challenge = u2fParams.challenge; + this.signRequests = u2fParams.sign_requests.map(function(request) { + return _(request).omit('challenge'); + }); + } + + U2FAuthenticate.prototype.start = function() { + if (U2FUtil.isU2FSupported()) { + return this.renderSetup(); + } else { + return this.renderNotSupported(); + } + }; + + U2FAuthenticate.prototype.authenticate = function() { + return u2f.sign(this.appId, this.challenge, this.signRequests, (function(_this) { + return function(response) { + var error; + if (response.errorCode) { + error = new U2FError(response.errorCode); + return _this.renderError(error); + } else { + return _this.renderAuthenticated(JSON.stringify(response)); + } + }; + })(this), 10); + }; + + U2FAuthenticate.prototype.templates = { + "notSupported": "#js-authenticate-u2f-not-supported", + "setup": '#js-authenticate-u2f-setup', + "inProgress": '#js-authenticate-u2f-in-progress', + "error": '#js-authenticate-u2f-error', + "authenticated": '#js-authenticate-u2f-authenticated' + }; + + U2FAuthenticate.prototype.renderTemplate = function(name, params) { + var template, templateString; + templateString = $(this.templates[name]).html(); + template = _.template(templateString); + return this.container.html(template(params)); + }; + + U2FAuthenticate.prototype.renderSetup = function() { + this.renderTemplate('setup'); + return this.container.find('#js-login-u2f-device').on('click', this.renderInProgress); + }; + + U2FAuthenticate.prototype.renderInProgress = function() { + this.renderTemplate('inProgress'); + return this.authenticate(); + }; + + U2FAuthenticate.prototype.renderError = function(error) { + this.renderTemplate('error', { + error_message: error.message() + }); + return this.container.find('#js-u2f-try-again').on('click', this.renderSetup); + }; + + U2FAuthenticate.prototype.renderAuthenticated = function(deviceResponse) { + this.renderTemplate('authenticated'); + return this.container.find("#js-device-response").val(deviceResponse); + }; + + U2FAuthenticate.prototype.renderNotSupported = function() { + return this.renderTemplate('notSupported'); + }; + + return U2FAuthenticate; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/u2f/authenticate.js.coffee b/app/assets/javascripts/u2f/authenticate.js.coffee deleted file mode 100644 index 918c0a560fd..00000000000 --- a/app/assets/javascripts/u2f/authenticate.js.coffee +++ /dev/null @@ -1,75 +0,0 @@ -# Authenticate U2F (universal 2nd factor) devices for users to authenticate with. -# -# State Flow #1: setup -> in_progress -> authenticated -> POST to server -# State Flow #2: setup -> in_progress -> error -> setup - -class @U2FAuthenticate - constructor: (@container, u2fParams) -> - @appId = u2fParams.app_id - @challenge = u2fParams.challenge - - # The U2F Javascript API v1.1 requires a single challenge, with - # _no challenges per-request_. The U2F Javascript API v1.0 requires a - # challenge per-request, which is done by copying the single challenge - # into every request. - # - # In either case, we don't need the per-request challenges that the server - # has generated, so we can remove them. - # - # Note: The server library fixes this behaviour in (unreleased) version 1.0.0. - # This can be removed once we upgrade. - # https://github.com/castle/ruby-u2f/commit/103f428071a81cd3d5f80c2e77d522d5029946a4 - @signRequests = u2fParams.sign_requests.map (request) -> _(request).omit('challenge') - - start: () => - if U2FUtil.isU2FSupported() - @renderSetup() - else - @renderNotSupported() - - authenticate: () => - u2f.sign(@appId, @challenge, @signRequests, (response) => - if response.errorCode - error = new U2FError(response.errorCode) - @renderError(error); - else - @renderAuthenticated(JSON.stringify(response)) - , 10) - - ############# - # Rendering # - ############# - - templates: { - "notSupported": "#js-authenticate-u2f-not-supported", - "setup": '#js-authenticate-u2f-setup', - "inProgress": '#js-authenticate-u2f-in-progress', - "error": '#js-authenticate-u2f-error', - "authenticated": '#js-authenticate-u2f-authenticated' - } - - renderTemplate: (name, params) => - templateString = $(@templates[name]).html() - template = _.template(templateString) - @container.html(template(params)) - - renderSetup: () => - @renderTemplate('setup') - @container.find('#js-login-u2f-device').on('click', @renderInProgress) - - renderInProgress: () => - @renderTemplate('inProgress') - @authenticate() - - renderError: (error) => - @renderTemplate('error', {error_message: error.message()}) - @container.find('#js-u2f-try-again').on('click', @renderSetup) - - renderAuthenticated: (deviceResponse) => - @renderTemplate('authenticated') - # Prefer to do this instead of interpolating using Underscore templates - # because of JSON escaping issues. - @container.find("#js-device-response").val(deviceResponse) - - renderNotSupported: () => - @renderTemplate('notSupported') diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js new file mode 100644 index 00000000000..bc48c67c4f2 --- /dev/null +++ b/app/assets/javascripts/u2f/error.js @@ -0,0 +1,27 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.U2FError = (function() { + function U2FError(errorCode) { + this.errorCode = errorCode; + this.message = bind(this.message, this); + this.httpsDisabled = window.location.protocol !== 'https:'; + console.error("U2F Error Code: " + this.errorCode); + } + + U2FError.prototype.message = function() { + switch (false) { + case !(this.errorCode === u2f.ErrorCodes.BAD_REQUEST && this.httpsDisabled): + return "U2F only works with HTTPS-enabled websites. Contact your administrator for more details."; + case this.errorCode !== u2f.ErrorCodes.DEVICE_INELIGIBLE: + return "This device has already been registered with us."; + default: + return "There was a problem communicating with your device."; + } + }; + + return U2FError; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/u2f/error.js.coffee b/app/assets/javascripts/u2f/error.js.coffee deleted file mode 100644 index 1a2fc3e757f..00000000000 --- a/app/assets/javascripts/u2f/error.js.coffee +++ /dev/null @@ -1,13 +0,0 @@ -class @U2FError - constructor: (@errorCode) -> - @httpsDisabled = (window.location.protocol isnt 'https:') - console.error("U2F Error Code: #{@errorCode}") - - message: () => - switch - when (@errorCode is u2f.ErrorCodes.BAD_REQUEST and @httpsDisabled) - "U2F only works with HTTPS-enabled websites. Contact your administrator for more details." - when @errorCode is u2f.ErrorCodes.DEVICE_INELIGIBLE - "This device has already been registered with us." - else - "There was a problem communicating with your device." diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js new file mode 100644 index 00000000000..c87e0840df3 --- /dev/null +++ b/app/assets/javascripts/u2f/register.js @@ -0,0 +1,87 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.U2FRegister = (function() { + function U2FRegister(container, u2fParams) { + this.container = container; + this.renderNotSupported = bind(this.renderNotSupported, this); + this.renderRegistered = bind(this.renderRegistered, this); + this.renderError = bind(this.renderError, this); + this.renderInProgress = bind(this.renderInProgress, this); + this.renderSetup = bind(this.renderSetup, this); + this.renderTemplate = bind(this.renderTemplate, this); + this.register = bind(this.register, this); + this.start = bind(this.start, this); + this.appId = u2fParams.app_id; + this.registerRequests = u2fParams.register_requests; + this.signRequests = u2fParams.sign_requests; + } + + U2FRegister.prototype.start = function() { + if (U2FUtil.isU2FSupported()) { + return this.renderSetup(); + } else { + return this.renderNotSupported(); + } + }; + + U2FRegister.prototype.register = function() { + return u2f.register(this.appId, this.registerRequests, this.signRequests, (function(_this) { + return function(response) { + var error; + if (response.errorCode) { + error = new U2FError(response.errorCode); + return _this.renderError(error); + } else { + return _this.renderRegistered(JSON.stringify(response)); + } + }; + })(this), 10); + }; + + U2FRegister.prototype.templates = { + "notSupported": "#js-register-u2f-not-supported", + "setup": '#js-register-u2f-setup', + "inProgress": '#js-register-u2f-in-progress', + "error": '#js-register-u2f-error', + "registered": '#js-register-u2f-registered' + }; + + U2FRegister.prototype.renderTemplate = function(name, params) { + var template, templateString; + templateString = $(this.templates[name]).html(); + template = _.template(templateString); + return this.container.html(template(params)); + }; + + U2FRegister.prototype.renderSetup = function() { + this.renderTemplate('setup'); + return this.container.find('#js-setup-u2f-device').on('click', this.renderInProgress); + }; + + U2FRegister.prototype.renderInProgress = function() { + this.renderTemplate('inProgress'); + return this.register(); + }; + + U2FRegister.prototype.renderError = function(error) { + this.renderTemplate('error', { + error_message: error.message() + }); + return this.container.find('#js-u2f-try-again').on('click', this.renderSetup); + }; + + U2FRegister.prototype.renderRegistered = function(deviceResponse) { + this.renderTemplate('registered'); + return this.container.find("#js-device-response").val(deviceResponse); + }; + + U2FRegister.prototype.renderNotSupported = function() { + return this.renderTemplate('notSupported'); + }; + + return U2FRegister; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/u2f/register.js.coffee b/app/assets/javascripts/u2f/register.js.coffee deleted file mode 100644 index 74472cfa120..00000000000 --- a/app/assets/javascripts/u2f/register.js.coffee +++ /dev/null @@ -1,63 +0,0 @@ -# Register U2F (universal 2nd factor) devices for users to authenticate with. -# -# State Flow #1: setup -> in_progress -> registered -> POST to server -# State Flow #2: setup -> in_progress -> error -> setup - -class @U2FRegister - constructor: (@container, u2fParams) -> - @appId = u2fParams.app_id - @registerRequests = u2fParams.register_requests - @signRequests = u2fParams.sign_requests - - start: () => - if U2FUtil.isU2FSupported() - @renderSetup() - else - @renderNotSupported() - - register: () => - u2f.register(@appId, @registerRequests, @signRequests, (response) => - if response.errorCode - error = new U2FError(response.errorCode) - @renderError(error); - else - @renderRegistered(JSON.stringify(response)) - , 10) - - ############# - # Rendering # - ############# - - templates: { - "notSupported": "#js-register-u2f-not-supported", - "setup": '#js-register-u2f-setup', - "inProgress": '#js-register-u2f-in-progress', - "error": '#js-register-u2f-error', - "registered": '#js-register-u2f-registered' - } - - renderTemplate: (name, params) => - templateString = $(@templates[name]).html() - template = _.template(templateString) - @container.html(template(params)) - - renderSetup: () => - @renderTemplate('setup') - @container.find('#js-setup-u2f-device').on('click', @renderInProgress) - - renderInProgress: () => - @renderTemplate('inProgress') - @register() - - renderError: (error) => - @renderTemplate('error', {error_message: error.message()}) - @container.find('#js-u2f-try-again').on('click', @renderSetup) - - renderRegistered: (deviceResponse) => - @renderTemplate('registered') - # Prefer to do this instead of interpolating using Underscore templates - # because of JSON escaping issues. - @container.find("#js-device-response").val(deviceResponse) - - renderNotSupported: () => - @renderTemplate('notSupported') diff --git a/app/assets/javascripts/u2f/util.js b/app/assets/javascripts/u2f/util.js new file mode 100644 index 00000000000..907e640161a --- /dev/null +++ b/app/assets/javascripts/u2f/util.js @@ -0,0 +1,13 @@ +(function() { + this.U2FUtil = (function() { + function U2FUtil() {} + + U2FUtil.isU2FSupported = function() { + return window.u2f; + }; + + return U2FUtil; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/u2f/util.js.coffee b/app/assets/javascripts/u2f/util.js.coffee deleted file mode 100644 index 5ef324f609d..00000000000 --- a/app/assets/javascripts/u2f/util.js.coffee +++ /dev/null @@ -1,3 +0,0 @@ -class @U2FUtil - @isU2FSupported: -> - window.u2f diff --git a/app/assets/javascripts/user.js b/app/assets/javascripts/user.js new file mode 100644 index 00000000000..b46390ad8f4 --- /dev/null +++ b/app/assets/javascripts/user.js @@ -0,0 +1,31 @@ +(function() { + this.User = (function() { + function User(opts) { + this.opts = opts; + $('.profile-groups-avatars').tooltip({ + "placement": "top" + }); + this.initTabs(); + $('.hide-project-limit-message').on('click', function(e) { + var path; + path = '/'; + $.cookie('hide_project_limit_message', 'false', { + path: path + }); + $(this).parents('.project-limit-message').remove(); + return e.preventDefault(); + }); + } + + User.prototype.initTabs = function() { + return new UserTabs({ + parentEl: '.user-profile', + action: this.opts.action + }); + }; + + return User; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/user.js.coffee b/app/assets/javascripts/user.js.coffee deleted file mode 100644 index 2882a90d118..00000000000 --- a/app/assets/javascripts/user.js.coffee +++ /dev/null @@ -1,17 +0,0 @@ -class @User - constructor: (@opts) -> - $('.profile-groups-avatars').tooltip("placement": "top") - - @initTabs() - - $('.hide-project-limit-message').on 'click', (e) -> - path = '/' - $.cookie('hide_project_limit_message', 'false', { path: path }) - $(@).parents('.project-limit-message').remove() - e.preventDefault() - - initTabs: -> - new UserTabs( - parentEl: '.user-profile' - action: @opts.action - ) diff --git a/app/assets/javascripts/user_tabs.js b/app/assets/javascripts/user_tabs.js new file mode 100644 index 00000000000..e5e75701fee --- /dev/null +++ b/app/assets/javascripts/user_tabs.js @@ -0,0 +1,119 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.UserTabs = (function() { + function UserTabs(opts) { + this.tabShown = bind(this.tabShown, this); + var i, item, len, ref, ref1, ref2, ref3; + this.action = (ref = opts.action) != null ? ref : 'activity', this.defaultAction = (ref1 = opts.defaultAction) != null ? ref1 : 'activity', this.parentEl = (ref2 = opts.parentEl) != null ? ref2 : $(document); + if (typeof this.parentEl === 'string') { + this.parentEl = $(this.parentEl); + } + this._location = location; + this.loaded = {}; + ref3 = this.parentEl.find('.nav-links a'); + for (i = 0, len = ref3.length; i < len; i++) { + item = ref3[i]; + this.loaded[$(item).attr('data-action')] = false; + } + this.actions = Object.keys(this.loaded); + this.bindEvents(); + if (this.action === 'show') { + this.action = this.defaultAction; + } + this.activateTab(this.action); + } + + UserTabs.prototype.bindEvents = function() { + return this.parentEl.off('shown.bs.tab', '.nav-links a[data-toggle="tab"]').on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', this.tabShown); + }; + + UserTabs.prototype.tabShown = function(event) { + var $target, action, source; + $target = $(event.target); + action = $target.data('action'); + source = $target.attr('href'); + this.setTab(source, action); + return this.setCurrentAction(action); + }; + + UserTabs.prototype.activateTab = function(action) { + return this.parentEl.find(".nav-links .js-" + action + "-tab a").tab('show'); + }; + + UserTabs.prototype.setTab = function(source, action) { + if (this.loaded[action] === true) { + return; + } + if (action === 'activity') { + this.loadActivities(source); + } + if (action === 'groups' || action === 'contributed' || action === 'projects' || action === 'snippets') { + return this.loadTab(source, action); + } + }; + + UserTabs.prototype.loadTab = function(source, action) { + return $.ajax({ + beforeSend: (function(_this) { + return function() { + return _this.toggleLoading(true); + }; + })(this), + complete: (function(_this) { + return function() { + return _this.toggleLoading(false); + }; + })(this), + dataType: 'json', + type: 'GET', + url: source + ".json", + success: (function(_this) { + return function(data) { + var tabSelector; + tabSelector = 'div#' + action; + _this.parentEl.find(tabSelector).html(data.html); + _this.loaded[action] = true; + return gl.utils.localTimeAgo($('.js-timeago', tabSelector)); + }; + })(this) + }); + }; + + UserTabs.prototype.loadActivities = function(source) { + var $calendarWrap; + if (this.loaded['activity'] === true) { + return; + } + $calendarWrap = this.parentEl.find('.user-calendar'); + $calendarWrap.load($calendarWrap.data('href')); + new Activities(); + return this.loaded['activity'] = true; + }; + + UserTabs.prototype.toggleLoading = function(status) { + return this.parentEl.find('.loading-status .loading').toggle(status); + }; + + UserTabs.prototype.setCurrentAction = function(action) { + var new_state, regExp; + regExp = new RegExp('\/(' + this.actions.join('|') + ')(\.html)?\/?$'); + new_state = this._location.pathname; + new_state = new_state.replace(/\/+$/, ""); + new_state = new_state.replace(regExp, ''); + if (action !== this.defaultAction) { + new_state += "/" + action; + } + new_state += this._location.search + this._location.hash; + history.replaceState({ + turbolinks: true, + url: new_state + }, document.title, new_state); + return new_state; + }; + + return UserTabs; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/user_tabs.js.coffee b/app/assets/javascripts/user_tabs.js.coffee deleted file mode 100644 index 29dad21faed..00000000000 --- a/app/assets/javascripts/user_tabs.js.coffee +++ /dev/null @@ -1,156 +0,0 @@ -# UserTabs -# -# Handles persisting and restoring the current tab selection and lazily-loading -# content on the Users#show page. -# -# ### Example Markup -# -# <ul class="nav-links"> -# <li class="activity-tab active"> -# <a data-action="activity" data-target="#activity" data-toggle="tab" href="/u/username"> -# Activity -# </a> -# </li> -# <li class="groups-tab"> -# <a data-action="groups" data-target="#groups" data-toggle="tab" href="/u/username/groups"> -# Groups -# </a> -# </li> -# <li class="contributed-tab"> -# <a data-action="contributed" data-target="#contributed" data-toggle="tab" href="/u/username/contributed"> -# Contributed projects -# </a> -# </li> -# <li class="projects-tab"> -# <a data-action="projects" data-target="#projects" data-toggle="tab" href="/u/username/projects"> -# Personal projects -# </a> -# </li> -# <li class="snippets-tab"> -# <a data-action="snippets" data-target="#snippets" data-toggle="tab" href="/u/username/snippets"> -# </a> -# </li> -# </ul> -# -# <div class="tab-content"> -# <div class="tab-pane" id="activity"> -# Activity Content -# </div> -# <div class="tab-pane" id="groups"> -# Groups Content -# </div> -# <div class="tab-pane" id="contributed"> -# Contributed projects content -# </div> -# <div class="tab-pane" id="projects"> -# Projects content -# </div> -# <div class="tab-pane" id="snippets"> -# Snippets content -# </div> -# </div> -# -# <div class="loading-status"> -# <div class="loading"> -# Loading Animation -# </div> -# </div> -# -class @UserTabs - constructor: (opts) -> - { - @action = 'activity' - @defaultAction = 'activity' - @parentEl = $(document) - } = opts - - # Make jQuery object if selector is provided - @parentEl = $(@parentEl) if typeof @parentEl is 'string' - - # Store the `location` object, allowing for easier stubbing in tests - @_location = location - - # Set tab states - @loaded = {} - for item in @parentEl.find('.nav-links a') - @loaded[$(item).attr 'data-action'] = false - - # Actions - @actions = Object.keys @loaded - - @bindEvents() - - # Set active tab - @action = @defaultAction if @action is 'show' - @activateTab(@action) - - bindEvents: -> - # Toggle event listeners - @parentEl - .off 'shown.bs.tab', '.nav-links a[data-toggle="tab"]' - .on 'shown.bs.tab', '.nav-links a[data-toggle="tab"]', @tabShown - - tabShown: (event) => - $target = $(event.target) - action = $target.data('action') - source = $target.attr('href') - - @setTab(source, action) - @setCurrentAction(action) - - activateTab: (action) -> - @parentEl.find(".nav-links .js-#{action}-tab a").tab('show') - - setTab: (source, action) -> - return if @loaded[action] is true - - if action is 'activity' - @loadActivities(source) - - if action in ['groups', 'contributed', 'projects', 'snippets'] - @loadTab(source, action) - - loadTab: (source, action) -> - $.ajax - beforeSend: => @toggleLoading(true) - complete: => @toggleLoading(false) - dataType: 'json' - type: 'GET' - url: "#{source}.json" - success: (data) => - tabSelector = 'div#' + action - @parentEl.find(tabSelector).html(data.html) - @loaded[action] = true - - # Fix tooltips - gl.utils.localTimeAgo($('.js-timeago', tabSelector)) - - loadActivities: (source) -> - return if @loaded['activity'] is true - - $calendarWrap = @parentEl.find('.user-calendar') - $calendarWrap.load($calendarWrap.data('href')) - - new Activities() - @loaded['activity'] = true - - toggleLoading: (status) -> - @parentEl.find('.loading-status .loading').toggle(status) - - setCurrentAction: (action) -> - # Remove possible actions from URL - regExp = new RegExp('\/(' + @actions.join('|') + ')(\.html)?\/?$') - new_state = @_location.pathname - new_state = new_state.replace(/\/+$/, "") # remove trailing slashes - new_state = new_state.replace(regExp, '') - - # Append the new action if we're on a tab other than 'activity' - unless action == @defaultAction - new_state += "/#{action}" - - # Ensure parameters and hash come along for the ride - new_state += @_location.search + @_location.hash - - history.replaceState {turbolinks: true, url: new_state}, document.title, new_state - - new_state diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js new file mode 100644 index 00000000000..8b3dbf5f5ae --- /dev/null +++ b/app/assets/javascripts/users/calendar.js @@ -0,0 +1,192 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Calendar = (function() { + function Calendar(timestamps, calendar_activities_path) { + var group, i; + this.calendar_activities_path = calendar_activities_path; + this.clickDay = bind(this.clickDay, this); + this.currentSelectedDate = ''; + this.daySpace = 1; + this.daySize = 15; + this.daySizeWithSpace = this.daySize + (this.daySpace * 2); + this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + this.months = []; + this.timestampsTmp = []; + i = 0; + group = 0; + _.each(timestamps, (function(_this) { + return function(count, date) { + var day, innerArray, newDate; + newDate = new Date(parseInt(date) * 1000); + day = newDate.getDay(); + if ((day === 0 && i !== 0) || i === 0) { + _this.timestampsTmp.push([]); + group++; + } + innerArray = _this.timestampsTmp[group - 1]; + innerArray.push({ + count: count, + date: newDate, + day: day + }); + return i++; + }; + })(this)); + this.colorKey = this.initColorKey(); + this.color = this.initColor(); + this.renderSvg(group); + this.renderDays(); + this.renderMonths(); + this.renderDayTitles(); + this.renderKey(); + this.initTooltips(); + } + + Calendar.prototype.renderSvg = function(group) { + return this.svg = d3.select('.js-contrib-calendar').append('svg').attr('width', (group + 1) * this.daySizeWithSpace).attr('height', 167).attr('class', 'contrib-calendar'); + }; + + Calendar.prototype.renderDays = function() { + return this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g').attr('transform', (function(_this) { + return function(group, i) { + _.each(group, function(stamp, a) { + var lastMonth, lastMonthX, month, x; + if (a === 0 && stamp.day === 0) { + month = stamp.date.getMonth(); + x = (_this.daySizeWithSpace * i + 1) + _this.daySizeWithSpace; + lastMonth = _.last(_this.months); + if (lastMonth != null) { + lastMonthX = lastMonth.x; + } + if (lastMonth == null) { + return _this.months.push({ + month: month, + x: x + }); + } else if (month !== lastMonth.month && x - _this.daySizeWithSpace !== lastMonthX) { + return _this.months.push({ + month: month, + x: x + }); + } + } + }); + return "translate(" + ((_this.daySizeWithSpace * i + 1) + _this.daySizeWithSpace) + ", 18)"; + }; + })(this)).selectAll('rect').data(function(stamp) { + return stamp; + }).enter().append('rect').attr('x', '0').attr('y', (function(_this) { + return function(stamp, i) { + return _this.daySizeWithSpace * stamp.day; + }; + })(this)).attr('width', this.daySize).attr('height', this.daySize).attr('title', (function(_this) { + return function(stamp) { + var contribText, date, dateText; + date = new Date(stamp.date); + contribText = 'No contributions'; + if (stamp.count > 0) { + contribText = stamp.count + " contribution" + (stamp.count > 1 ? 's' : ''); + } + dateText = dateFormat(date, 'mmm d, yyyy'); + return contribText + "<br />" + (gl.utils.getDayName(date)) + " " + dateText; + }; + })(this)).attr('class', 'user-contrib-cell js-tooltip').attr('fill', (function(_this) { + return function(stamp) { + if (stamp.count !== 0) { + return _this.color(Math.min(stamp.count, 40)); + } else { + return '#ededed'; + } + }; + })(this)).attr('data-container', 'body').on('click', this.clickDay); + }; + + Calendar.prototype.renderDayTitles = function() { + var days; + days = [ + { + text: 'M', + y: 29 + (this.daySizeWithSpace * 1) + }, { + text: 'W', + y: 29 + (this.daySizeWithSpace * 3) + }, { + text: 'F', + y: 29 + (this.daySizeWithSpace * 5) + } + ]; + return this.svg.append('g').selectAll('text').data(days).enter().append('text').attr('text-anchor', 'middle').attr('x', 8).attr('y', function(day) { + return day.y; + }).text(function(day) { + return day.text; + }).attr('class', 'user-contrib-text'); + }; + + Calendar.prototype.renderMonths = function() { + return this.svg.append('g').selectAll('text').data(this.months).enter().append('text').attr('x', function(date) { + return date.x; + }).attr('y', 10).attr('class', 'user-contrib-text').text((function(_this) { + return function(date) { + return _this.monthNames[date.month]; + }; + })(this)); + }; + + 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; + }); + }; + + Calendar.prototype.initColor = function() { + var colorRange; + colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; + return d3.scale.threshold().domain([0, 10, 20, 30]).range(colorRange); + }; + + Calendar.prototype.initColorKey = function() { + return d3.scale.linear().range(['#acd5f2', '#254e77']).domain([0, 3]); + }; + + Calendar.prototype.clickDay = function(stamp) { + var formatted_date; + if (this.currentSelectedDate !== stamp.date) { + this.currentSelectedDate = stamp.date; + formatted_date = this.currentSelectedDate.getFullYear() + "-" + (this.currentSelectedDate.getMonth() + 1) + "-" + this.currentSelectedDate.getDate(); + return $.ajax({ + url: this.calendar_activities_path, + data: { + date: formatted_date + }, + cache: false, + dataType: 'html', + beforeSend: function() { + return $('.user-calendar-activities').html('<div class="text-center"><i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i></div>'); + }, + success: function(data) { + return $('.user-calendar-activities').html(data); + } + }); + } else { + return $('.user-calendar-activities').html(''); + } + }; + + Calendar.prototype.initTooltips = function() { + return $('.js-contrib-calendar .js-tooltip').tooltip({ + html: true + }); + }; + + return Calendar; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/users/calendar.js.coffee b/app/assets/javascripts/users/calendar.js.coffee deleted file mode 100644 index c49ba5186f2..00000000000 --- a/app/assets/javascripts/users/calendar.js.coffee +++ /dev/null @@ -1,194 +0,0 @@ -class @Calendar - constructor: (timestamps, @calendar_activities_path) -> - @currentSelectedDate = '' - @daySpace = 1 - @daySize = 15 - @daySizeWithSpace = @daySize + (@daySpace * 2) - @monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] - @months = [] - - # Loop through the timestamps to create a group of objects - # The group of objects will be grouped based on the day of the week they are - @timestampsTmp = [] - i = 0 - group = 0 - _.each timestamps, (count, date) => - newDate = new Date parseInt(date) * 1000 - day = newDate.getDay() - - # Create a new group array if this is the first day of the week - # or if is first object - if (day is 0 and i isnt 0) or i is 0 - @timestampsTmp.push [] - group++ - - innerArray = @timestampsTmp[group-1] - - # Push to the inner array the values that will be used to render map - innerArray.push - count: count - date: newDate - day: day - - i++ - - # Init color functions - @colorKey = @initColorKey() - @color = @initColor() - - # Init the svg element - @renderSvg(group) - @renderDays() - @renderMonths() - @renderDayTitles() - @renderKey() - - @initTooltips() - - renderSvg: (group) -> - @svg = d3.select '.js-contrib-calendar' - .append 'svg' - .attr 'width', (group + 1) * @daySizeWithSpace - .attr 'height', 167 - .attr 'class', 'contrib-calendar' - - renderDays: -> - @svg.selectAll 'g' - .data @timestampsTmp - .enter() - .append 'g' - .attr 'transform', (group, i) => - _.each group, (stamp, a) => - if a is 0 and stamp.day is 0 - month = stamp.date.getMonth() - x = (@daySizeWithSpace * i + 1) + @daySizeWithSpace - lastMonth = _.last(@months) - if lastMonth? - lastMonthX = lastMonth.x - - if !lastMonth? - @months.push - month: month - x: x - else if month isnt lastMonth.month and x - @daySizeWithSpace isnt lastMonthX - @months.push - month: month - x: x - - "translate(#{(@daySizeWithSpace * i + 1) + @daySizeWithSpace}, 18)" - .selectAll 'rect' - .data (stamp) -> - stamp - .enter() - .append 'rect' - .attr 'x', '0' - .attr 'y', (stamp, i) => - (@daySizeWithSpace * stamp.day) - .attr 'width', @daySize - .attr 'height', @daySize - .attr 'title', (stamp) => - date = new Date(stamp.date) - contribText = 'No contributions' - - if stamp.count > 0 - contribText = "#{stamp.count} contribution#{if stamp.count > 1 then 's' else ''}" - - dateText = dateFormat(date, 'mmm d, yyyy') - - "#{contribText}<br />#{gl.utils.getDayName(date)} #{dateText}" - .attr 'class', 'user-contrib-cell js-tooltip' - .attr 'fill', (stamp) => - if stamp.count isnt 0 - @color(Math.min(stamp.count, 40)) - else - '#ededed' - .attr 'data-container', 'body' - .on 'click', @clickDay - - renderDayTitles: -> - days = [{ - text: 'M' - y: 29 + (@daySizeWithSpace * 1) - }, { - text: 'W' - y: 29 + (@daySizeWithSpace * 3) - }, { - text: 'F' - y: 29 + (@daySizeWithSpace * 5) - }] - @svg.append 'g' - .selectAll 'text' - .data days - .enter() - .append 'text' - .attr 'text-anchor', 'middle' - .attr 'x', 8 - .attr 'y', (day) -> - day.y - .text (day) -> - day.text - .attr 'class', 'user-contrib-text' - - renderMonths: -> - @svg.append 'g' - .selectAll 'text' - .data @months - .enter() - .append 'text' - .attr 'x', (date) -> - date.x - .attr 'y', 10 - .attr 'class', 'user-contrib-text' - .text (date) => - @monthNames[date.month] - - renderKey: -> - keyColors = ['#ededed', @colorKey(0), @colorKey(1), @colorKey(2), @colorKey(3)] - @svg.append 'g' - .attr 'transform', "translate(18, #{@daySizeWithSpace * 8 + 16})" - .selectAll 'rect' - .data keyColors - .enter() - .append 'rect' - .attr 'width', @daySize - .attr 'height', @daySize - .attr 'x', (color, i) => - @daySizeWithSpace * i - .attr 'y', 0 - .attr 'fill', (color) -> - color - - initColor: -> - colorRange = ['#ededed', @colorKey(0), @colorKey(1), @colorKey(2), @colorKey(3)] - d3.scale - .threshold() - .domain([0, 10, 20, 30]) - .range(colorRange) - - initColorKey: -> - d3.scale - .linear() - .range(['#acd5f2', '#254e77']) - .domain([0, 3]) - - clickDay: (stamp) => - if @currentSelectedDate isnt stamp.date - @currentSelectedDate = stamp.date - formatted_date = @currentSelectedDate.getFullYear() + "-" + (@currentSelectedDate.getMonth()+1) + "-" + @currentSelectedDate.getDate() - - $.ajax - url: @calendar_activities_path - data: - date: formatted_date - cache: false - dataType: 'html' - beforeSend: -> - $('.user-calendar-activities').html '<div class="text-center"><i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i></div>' - success: (data) -> - $('.user-calendar-activities').html data - else - $('.user-calendar-activities').html '' - - initTooltips: -> - $('.js-contrib-calendar .js-tooltip').tooltip - html: true diff --git a/app/assets/javascripts/users/users_bundle.js b/app/assets/javascripts/users/users_bundle.js new file mode 100644 index 00000000000..b95faadc8e7 --- /dev/null +++ b/app/assets/javascripts/users/users_bundle.js @@ -0,0 +1,7 @@ + +/*= require_tree . */ + +(function() { + + +}).call(this); diff --git a/app/assets/javascripts/users/users_bundle.js.coffee b/app/assets/javascripts/users/users_bundle.js.coffee deleted file mode 100644 index 91cacfece46..00000000000 --- a/app/assets/javascripts/users/users_bundle.js.coffee +++ /dev/null @@ -1,2 +0,0 @@ -# -#= require_tree . diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js new file mode 100644 index 00000000000..65d362e072c --- /dev/null +++ b/app/assets/javascripts/users_select.js @@ -0,0 +1,350 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }, + slice = [].slice; + + this.UsersSelect = (function() { + function UsersSelect(currentUser) { + this.users = bind(this.users, this); + this.user = bind(this.user, this); + this.usersPath = "/autocomplete/users.json"; + this.userPath = "/autocomplete/users/:id.json"; + if (currentUser != null) { + this.currentUser = JSON.parse(currentUser); + } + $('.js-user-search').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; + $dropdown = $(dropdown); + options.projectId = $dropdown.data('project-id'); + options.showCurrentUser = $dropdown.data('current-user'); + showNullUser = $dropdown.data('null-user'); + 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'); + $block = $selectbox.closest('.block'); + abilityName = $dropdown.data('ability-name'); + $value = $block.find('.value'); + $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + $loading = $block.find('.block-loading').fadeOut(); + $block.on('click', '.js-assign-yourself', function(e) { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); + assignTo = function(selected) { + var data; + data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + $loading.fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + return $.ajax({ + type: 'PUT', + dataType: 'json', + url: issueURL, + data: data + }).done(function(data) { + var user; + $dropdown.trigger('loaded.gl.dropdown'); + $loading.fadeOut(); + $selectbox.hide(); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url + }; + } else { + user = { + name: 'Unassigned', + username: '', + avatar: '' + }; + } + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/u/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); + assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/u/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); + return $dropdown.glDropdown({ + data: function(term, callback) { + 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) { + for (index = j = 0, len = users.length; j < len; index = ++j) { + 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 (showDivider) { + users.splice(showDivider, 0, "divider"); + } + return callback(users); + }); + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'] + }, + selectable: true, + fieldName: $dropdown.data('field-name'), + toggleLabel: function(selected) { + if (selected && 'id' in selected) { + if (selected.text) { + return selected.text; + } else { + return selected.name; + } + } else { + return defaultLabel; + } + }, + inputId: 'issue_assignee_id', + hidden: function(e) { + $selectbox.hide(); + return $value.css('display', ''); + }, + clicked: function(user) { + 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')) { + return; + } + 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 { + selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); + return assignTo(selected); + } + }, + renderRow: function(user) { + var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username; + username = user.username ? "@" + user.username : ""; + avatar = user.avatar_url ? user.avatar_url : false; + selected = user.id === selectedId ? "is-active" : ""; + img = ""; + if (user.beforeDivider != null) { + "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>"; + } else { + if (avatar) { + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />"; + } + } + listWithName = "<li> <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; + } + }); + }; + })(this)); + $('.ajax-users-select').each((function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('project-id'); + options.groupId = $(select).data('group-id'); + options.showCurrentUser = $(select).data('current-user'); + options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); + options.authorId = $(select).data('author-id'); + options.skipUsers = $(select).data('skip-users'); + showNullUser = $(select).data('null-user'); + showAnyUser = $(select).data('any-user'); + showEmailUser = $(select).data('email-user'); + firstUser = $(select).data('first-user'); + return $(select).select2({ + placeholder: "Search for a user", + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; + data = { + results: users + }; + if (query.term.length === 0) { + if (firstUser) { + ref = data.results; + for (index = j = 0, len = ref.length; j < len; index = ++j) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } + } + } + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0 + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + name: name, + id: null + }; + data.results.unshift(anyUser); + } + } + if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + emailUser = { + name: "Invite \"" + query.term + "\"", + username: query.term, + id: query.term + }; + data.results.unshift(emailUser); + } + return query.callback(data); + }); + }, + initSelection: function() { + var args; + args = 1 <= arguments.length ? slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: "ajax-users-dropdown", + escapeMarkup: function(m) { + return m; + } + }); + }; + })(this)); + } + + UsersSelect.prototype.initSelection = function(element, callback) { + var id, nullUser; + id = $(element).val(); + if (id === "0") { + nullUser = { + name: 'Unassigned' + }; + return callback(nullUser); + } else if (id !== "") { + return this.user(id, callback); + } + }; + + UsersSelect.prototype.formatResult = function(user) { + var avatar; + if (user.avatar_url) { + avatar = user.avatar_url; + } else { + avatar = gon.default_avatar_url; + } + return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; + }; + + UsersSelect.prototype.formatSelection = function(user) { + return user.name; + }; + + UsersSelect.prototype.user = function(user_id, callback) { + var url; + url = this.buildUrl(this.userPath); + url = url.replace(':id', user_id); + return $.ajax({ + url: url, + dataType: "json" + }).done(function(user) { + return callback(user); + }); + }; + + UsersSelect.prototype.users = function(query, options, callback) { + var url; + url = this.buildUrl(this.usersPath); + return $.ajax({ + url: url, + data: { + search: query, + per_page: 20, + active: true, + project_id: options.projectId || null, + group_id: options.groupId || null, + skip_ldap: options.skipLdap || null, + current_user: options.showCurrentUser || null, + push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, + author_id: options.authorId || null, + skip_users: options.skipUsers || null + }, + dataType: "json" + }).done(function(users) { + return callback(users); + }); + }; + + UsersSelect.prototype.buildUrl = function(url) { + if (gon.relative_url_root != null) { + url = gon.relative_url_root.replace(/\/$/, '') + url; + } + return url; + }; + + return UsersSelect; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee deleted file mode 100644 index 344be811e0d..00000000000 --- a/app/assets/javascripts/users_select.js.coffee +++ /dev/null @@ -1,330 +0,0 @@ -class @UsersSelect - constructor: (currentUser) -> - @usersPath = "/autocomplete/users.json" - @userPath = "/autocomplete/users/:id.json" - if currentUser? - @currentUser = JSON.parse(currentUser) - - $('.js-user-search').each (i, dropdown) => - $dropdown = $(dropdown) - @projectId = $dropdown.data('project-id') - @showCurrentUser = $dropdown.data('current-user') - showNullUser = $dropdown.data('null-user') - showAnyUser = $dropdown.data('any-user') - firstUser = $dropdown.data('first-user') - @authorId = $dropdown.data('author-id') - selectedId = $dropdown.data('selected') - defaultLabel = $dropdown.data('default-label') - issueURL = $dropdown.data('issueUpdate') - $selectbox = $dropdown.closest('.selectbox') - $block = $selectbox.closest('.block') - abilityName = $dropdown.data('ability-name') - $value = $block.find('.value') - $collapsedSidebar = $block.find('.sidebar-collapsed-user') - $loading = $block.find('.block-loading').fadeOut() - - $block.on('click', '.js-assign-yourself', (e) => - e.preventDefault() - assignTo(@currentUser.id) - ) - - assignTo = (selected) -> - data = {} - data[abilityName] = {} - data[abilityName].assignee_id = if selected? then selected else null - $loading - .fadeIn() - $dropdown.trigger('loading.gl.dropdown') - $.ajax( - type: 'PUT' - dataType: 'json' - url: issueURL - data: data - ).done (data) -> - $dropdown.trigger('loaded.gl.dropdown') - $loading.fadeOut() - $selectbox.hide() - - if data.assignee - user = - name: data.assignee.name - username: data.assignee.username - avatar: data.assignee.avatar_url - else - user = - name: 'Unassigned' - username: '' - avatar: '' - $value.html(assigneeTemplate(user)) - - $collapsedSidebar - .attr('title', user.name) - .tooltip('fixTitle') - - $collapsedSidebar.html(collapsedAssigneeTemplate(user)) - - - collapsedAssigneeTemplate = _.template( - '<% if( avatar ) { %> - <a class="author_link" href="/u/<%- username %>"> - <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> - </a> - <% } else { %> - <i class="fa fa-user"></i> - <% } %>' - ) - - assigneeTemplate = _.template( - '<% if (username) { %> - <a class="author_link bold" href="/u/<%- username %>"> - <% if( avatar ) { %> - <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> - <% } %> - <span class="author"><%- name %></span> - <span class="username"> - @<%- username %> - </span> - </a> - <% } else { %> - <span class="no-value assign-yourself"> - No assignee - - <a href="#" class="js-assign-yourself"> - assign yourself - </a> - </span> - <% } %>' - ) - - $dropdown.glDropdown( - data: (term, callback) => - isAuthorFilter = $('.js-author-search') - - @users term, (users) => - if term.length is 0 - showDivider = 0 - - if firstUser - # Move current user to the front of the list - for obj, index in users - 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 - name = 'Any User' if name == true - anyUser = { - beforeDivider: true - name: name, - id: null - } - users.unshift(anyUser) - - if showDivider - users.splice(showDivider, 0, "divider") - - # Send the data back - callback users - filterable: true - filterRemote: true - search: - fields: ['name', 'username'] - selectable: true - fieldName: $dropdown.data('field-name') - - toggleLabel: (selected) -> - if selected && 'id' of selected - if selected.text then selected.text else selected.name - else - defaultLabel - - inputId: 'issue_assignee_id' - - hidden: (e) -> - $selectbox.hide() - # display:block overrides the hide-collapse rule - $value.css('display', '') - - clicked: (user) -> - page = $('body').data 'page' - isIssueIndex = page is 'projects:issues:index' - isMRIndex = page is page is 'projects:merge_requests:index' - if $dropdown.hasClass('js-filter-bulk-update') - return - - if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex) - selectedId = user.id - Issuable.filterResults $dropdown.closest('form') - else if $dropdown.hasClass 'js-filter-submit' - $dropdown.closest('form').submit() - else - selected = $dropdown - .closest('.selectbox') - .find("input[name='#{$dropdown.data('field-name')}']").val() - assignTo(selected) - - renderRow: (user) -> - username = if user.username then "@#{user.username}" else "" - avatar = if user.avatar_url then user.avatar_url else false - selected = if user.id is selectedId then "is-active" else "" - img = "" - - if user.beforeDivider? - "<li> - <a href='#' class='#{selected}'> - #{user.name} - </a> - </li>" - else - if avatar - img = "<img src='#{avatar}' class='avatar avatar-inline' width='30' />" - - # split into three parts so we can remove the username section if nessesary - listWithName = "<li> - <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 is '' - listWithUserName = '' - - listWithName + listWithUserName + listClosingTags - ) - - $('.ajax-users-select').each (i, select) => - @projectId = $(select).data('project-id') - @groupId = $(select).data('group-id') - @showCurrentUser = $(select).data('current-user') - @authorId = $(select).data('author-id') - showNullUser = $(select).data('null-user') - showAnyUser = $(select).data('any-user') - showEmailUser = $(select).data('email-user') - firstUser = $(select).data('first-user') - - $(select).select2 - placeholder: "Search for a user" - multiple: $(select).hasClass('multiselect') - minimumInputLength: 0 - query: (query) => - @users query.term, (users) => - data = { results: users } - - if query.term.length == 0 - if firstUser - # Move current user to the front of the list - for obj, index in data.results - if obj.username == firstUser - data.results.splice(index, 1) - data.results.unshift(obj) - break - - if showNullUser - nullUser = { - name: 'Unassigned', - id: 0 - } - data.results.unshift(nullUser) - - if showAnyUser - name = showAnyUser - name = 'Any User' if name == true - anyUser = { - name: name, - id: null - } - data.results.unshift(anyUser) - - if showEmailUser && data.results.length == 0 && query.term.match(/^[^@]+@[^@]+$/) - emailUser = { - name: "Invite \"#{query.term}\"", - username: query.term, - id: query.term - } - data.results.unshift(emailUser) - - query.callback(data) - - initSelection: (args...) => - @initSelection(args...) - formatResult: (args...) => - @formatResult(args...) - formatSelection: (args...) => - @formatSelection(args...) - dropdownCssClass: "ajax-users-dropdown" - escapeMarkup: (m) -> # we do not want to escape markup since we are displaying html in results - m - - initSelection: (element, callback) -> - id = $(element).val() - if id == "0" - nullUser = { name: 'Unassigned' } - callback(nullUser) - else if id != "" - @user(id, callback) - - formatResult: (user) -> - if user.avatar_url - avatar = user.avatar_url - else - avatar = gon.default_avatar_url - - "<div class='user-result #{'no-username' unless user.username}'> - <div class='user-image'><img class='avatar s24' src='#{avatar}'></div> - <div class='user-name'>#{user.name}</div> - <div class='user-username'>#{user.username || ""}</div> - </div>" - - formatSelection: (user) -> - user.name - - user: (user_id, callback) => - url = @buildUrl(@userPath) - url = url.replace(':id', user_id) - - $.ajax( - url: url - dataType: "json" - ).done (user) -> - callback(user) - - # Return users list. Filtered by query - # Only active users retrieved - users: (query, callback) => - url = @buildUrl(@usersPath) - - $.ajax( - url: url - data: - search: query - per_page: 20 - active: true - project_id: @projectId - group_id: @groupId - current_user: @showCurrentUser - author_id: @authorId - dataType: "json" - ).done (users) -> - callback(users) - - buildUrl: (url) -> - url = gon.relative_url_root.replace(/\/$/, '') + url if gon.relative_url_root? - return url diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js new file mode 100644 index 00000000000..35401231fbf --- /dev/null +++ b/app/assets/javascripts/wikis.js @@ -0,0 +1,37 @@ + +/*= require latinise */ + +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.Wikis = (function() { + function Wikis() { + this.slugify = bind(this.slugify, this); + $('.new-wiki-page').on('submit', (function(_this) { + return function(e) { + var field, path, slug; + $('[data-error~=slug]').addClass('hidden'); + field = $('#new_wiki_path'); + slug = _this.slugify(field.val()); + if (slug.length > 0) { + path = field.attr('data-wikis-path'); + location.href = path + '/' + slug; + return e.preventDefault(); + } + }; + })(this)); + } + + Wikis.prototype.dasherize = function(value) { + return value.replace(/[_\s]+/g, '-'); + }; + + Wikis.prototype.slugify = function(value) { + return this.dasherize(value.trim().toLowerCase().latinise()); + }; + + return Wikis; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/wikis.js.coffee b/app/assets/javascripts/wikis.js.coffee deleted file mode 100644 index 1ee827f1fa3..00000000000 --- a/app/assets/javascripts/wikis.js.coffee +++ /dev/null @@ -1,19 +0,0 @@ -#= require latinise - -class @Wikis - constructor: -> - $('.new-wiki-page').on 'submit', (e) => - $('[data-error~=slug]').addClass('hidden') - field = $('#new_wiki_path') - slug = @slugify(field.val()) - - if (slug.length > 0) - path = field.attr('data-wikis-path') - location.href = path + '/' + slug - e.preventDefault() - - dasherize: (value) -> - value.replace(/[_\s]+/g, '-') - - slugify: (value) => - @dasherize(value.trim().toLowerCase().latinise()) diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js new file mode 100644 index 00000000000..71236c6a27d --- /dev/null +++ b/app/assets/javascripts/zen_mode.js @@ -0,0 +1,80 @@ + +/*= provides zen_mode:enter */ + + +/*= provides zen_mode:leave */ + + +/*= require jquery.scrollTo */ + + +/*= require dropzone */ + + +/*= require mousetrap */ + + +/*= require mousetrap/pause */ + +(function() { + this.ZenMode = (function() { + function ZenMode() { + this.active_backdrop = null; + this.active_textarea = null; + $(document).on('click', '.js-zen-enter', function(e) { + e.preventDefault(); + return $(e.currentTarget).trigger('zen_mode:enter'); + }); + $(document).on('click', '.js-zen-leave', function(e) { + e.preventDefault(); + return $(e.currentTarget).trigger('zen_mode:leave'); + }); + $(document).on('zen_mode:enter', (function(_this) { + return function(e) { + return _this.enter($(e.target).closest('.md-area').find('.zen-backdrop')); + }; + })(this)); + $(document).on('zen_mode:leave', (function(_this) { + return function(e) { + return _this.exit(); + }; + })(this)); + $(document).on('keydown', function(e) { + if (e.keyCode === 27) { + e.preventDefault(); + return $(document).trigger('zen_mode:leave'); + } + }); + } + + ZenMode.prototype.enter = function(backdrop) { + Mousetrap.pause(); + this.active_backdrop = $(backdrop); + this.active_backdrop.addClass('fullscreen'); + this.active_textarea = this.active_backdrop.find('textarea'); + this.active_textarea.removeAttr('style'); + return this.active_textarea.focus(); + }; + + ZenMode.prototype.exit = function() { + if (this.active_textarea) { + Mousetrap.unpause(); + this.active_textarea.closest('.zen-backdrop').removeClass('fullscreen'); + this.scrollTo(this.active_textarea); + this.active_textarea = null; + this.active_backdrop = null; + return Dropzone.forElement('.div-dropzone').enable(); + } + }; + + ZenMode.prototype.scrollTo = function(zen_area) { + return $.scrollTo(zen_area, 0, { + offset: -150 + }); + }; + + return ZenMode; + + })(); + +}).call(this); diff --git a/app/assets/javascripts/zen_mode.js.coffee b/app/assets/javascripts/zen_mode.js.coffee deleted file mode 100644 index 99f35ecfb0f..00000000000 --- a/app/assets/javascripts/zen_mode.js.coffee +++ /dev/null @@ -1,80 +0,0 @@ -# Zen Mode (full screen) textarea -# -#= provides zen_mode:enter -#= provides zen_mode:leave -# -#= require jquery.scrollTo -#= require dropzone -#= require mousetrap -#= require mousetrap/pause -# -# ### Events -# -# `zen_mode:enter` -# -# Fired when the "Edit in fullscreen" link is clicked. -# -# **Synchronicity** Sync -# **Bubbles** Yes -# **Cancelable** No -# **Target** a.js-zen-enter -# -# `zen_mode:leave` -# -# Fired when the "Leave Fullscreen" link is clicked. -# -# **Synchronicity** Sync -# **Bubbles** Yes -# **Cancelable** No -# **Target** a.js-zen-leave -# -class @ZenMode - constructor: -> - @active_backdrop = null - @active_textarea = null - - $(document).on 'click', '.js-zen-enter', (e) -> - e.preventDefault() - $(e.currentTarget).trigger('zen_mode:enter') - - $(document).on 'click', '.js-zen-leave', (e) -> - e.preventDefault() - $(e.currentTarget).trigger('zen_mode:leave') - - $(document).on 'zen_mode:enter', (e) => - @enter($(e.target).closest('.md-area').find('.zen-backdrop')) - $(document).on 'zen_mode:leave', (e) => - @exit() - - $(document).on 'keydown', (e) -> - if e.keyCode == 27 # Esc - e.preventDefault() - $(document).trigger('zen_mode:leave') - - enter: (backdrop) -> - Mousetrap.pause() - - @active_backdrop = $(backdrop) - @active_backdrop.addClass('fullscreen') - - @active_textarea = @active_backdrop.find('textarea') - - # Prevent a user-resized textarea from persisting to fullscreen - @active_textarea.removeAttr('style') - @active_textarea.focus() - - exit: -> - if @active_textarea - Mousetrap.unpause() - - @active_textarea.closest('.zen-backdrop').removeClass('fullscreen') - - @scrollTo(@active_textarea) - - @active_textarea = null - @active_backdrop = null - - Dropzone.forElement('.div-dropzone').enable() - - scrollTo: (zen_area) -> - $.scrollTo(zen_area, 0, offset: -150) diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 8b6ddf8ba18..c79b22d4d21 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -5,6 +5,7 @@ height: 40px; padding: 0; @include border-radius($avatar_radius); + border: 1px solid rgba(0, 0, 0, .1); &.avatar-inline { float: none; @@ -15,8 +16,9 @@ &.s24 { margin-right: 4px; } } - &.group-avatar, &.project-avatar, &.avatar-tile { + &.avatar-tile { @include border-radius(0); + border: none; } &.s16 { width: 16px; height: 16px; margin-right: 6px; } @@ -43,12 +45,12 @@ &.s16 { font-size: 12px; line-height: 1.33; } &.s24 { font-size: 14px; line-height: 1.8; } &.s26 { font-size: 20px; line-height: 1.33; } - &.s32 { font-size: 20px; line-height: 32px; } - &.s40 { font-size: 16px; line-height: 40px; } - &.s60 { font-size: 32px; line-height: 60px; } - &.s70 { font-size: 34px; line-height: 70px; } - &.s90 { font-size: 36px; line-height: 90px; } - &.s110 { font-size: 40px; line-height: 112px; font-weight: 300; } - &.s140 { font-size: 72px; line-height: 140px; } - &.s160 { font-size: 96px; line-height: 160px; } + &.s32 { font-size: 20px; line-height: 30px; } + &.s40 { font-size: 16px; line-height: 38px; } + &.s60 { font-size: 32px; line-height: 58px; } + &.s70 { font-size: 34px; line-height: 68px; } + &.s90 { font-size: 36px; line-height: 88px; } + &.s110 { font-size: 40px; line-height: 108px; font-weight: 300; } + &.s140 { font-size: 72px; line-height: 138px; } + &.s160 { font-size: 96px; line-height: 158px; } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index f87b8a2ad1c..473530cf094 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -135,6 +135,15 @@ @include btn-green; } + &.btn-inverted { + &.btn-success, + &.btn-new, + &.btn-create, + &.btn-save { + @include btn-outline($white-light, $green-normal, $green-normal, $green-light, $white-light, $green-light); + } + } + &.btn-gray { @include btn-gray; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index d4e900f80ef..e8eafa15899 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -72,6 +72,14 @@ &.large { width: 200px; } + + &.wide { + width: 100%; + + + .dropdown-select { + width: 100%; + } + } } .dropdown-menu, @@ -350,6 +358,7 @@ .dropdown-input-field, .default-dropdown-input { width: 100%; + min-height: 30px; padding: 0 7px; color: $dropdown-input-color; line-height: 30px; diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 2c40ec430ca..965fcc06518 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -114,6 +114,12 @@ ul.content-list { font-size: $list-font-size; color: $list-text-color; + &.no-description { + .title { + line-height: $list-text-height; + } + } + .title { font-weight: 600; } @@ -134,12 +140,11 @@ ul.content-list { } .controls { - padding-top: 1px; float: right; > .control-text { margin-right: $gl-padding-top; - line-height: 40px; + line-height: $list-text-height; &:last-child { margin-right: 0; @@ -150,7 +155,7 @@ ul.content-list { > .btn-group { margin-right: $gl-padding-top; display: inline-block; - margin-top: 4px; + margin-top: 3px; margin-bottom: 4px; &:last-child { diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 364952d3b4a..7852fc9a424 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -182,7 +182,6 @@ > form { display: inline-block; - margin-top: -1px; } .icon-label { @@ -193,7 +192,6 @@ height: 35px; display: inline-block; position: relative; - top: 2px; margin-right: $gl-padding-top; /* Medium devices (desktops, 992px and up) */ diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss index 874416e1007..c6f30e144fd 100644 --- a/app/assets/stylesheets/framework/panels.scss +++ b/app/assets/stylesheets/framework/panels.scss @@ -23,4 +23,9 @@ margin-top: $gl-padding; } } + + .panel-title { + font-size: inherit; + line-height: inherit; + } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 1882d4e888d..ca720022539 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -43,6 +43,7 @@ $gl-header-color: $gl-title-color; $list-font-size: $gl-font-size; $list-title-color: $gl-title-color; $list-text-color: $gl-text-color; +$list-text-height: 42px; /* * Markdown diff --git a/app/assets/stylesheets/mailers/repository_push_email.scss b/app/assets/stylesheets/mailers/repository_push_email.scss index 7f645d3089d..33aedf1f7c1 100644 --- a/app/assets/stylesheets/mailers/repository_push_email.scss +++ b/app/assets/stylesheets/mailers/repository_push_email.scss @@ -10,83 +10,48 @@ // preference): plain class selectors, type (element name) selectors, or // explicit child selectors. -table.code { - width: 100%; +.code { + background-color: #fff; font-family: monospace; - border: none; - border-collapse: separate; - margin: 0; - padding: 0; + font-size: $code_font_size; -premailer-cellpadding: 0; -premailer-cellspacing: 0; -premailer-width: 100%; - > tr > td { + > tr { line-height: $code_line_height; - font-family: monospace; - font-size: $code_font_size; - - &.diff-line-num { - margin: 0; - padding: 0; - border: none; - padding: 0 5px; - border-right: 1px solid; - text-align: right; - min-width: 35px; - max-width: 50px; - width: 35px; - } - - &.line_content { - display: block; - margin: 0; - padding: 0 0.5em; - border: none; - white-space: pre; - } } } -.line-numbers, .diff-line-num { +.diff-line-num { + padding: 0 5px; + text-align: right; + width: 35px; background-color: $background-color; -} - -.diff-line-num, .diff-line-num a { color: $black-transparent; -} - -pre.code, .diff-line-num { - border-color: $table-border-gray; -} + border-right: 1px solid $table-border-gray; -.code.white, pre.code, .line_content { - background-color: #fff; - color: #333; -} - -.diff-line-num { &.old { background-color: $line-number-old; - border-color: $line-removed-dark; + border-right-color: $line-removed-dark; } &.new { background-color: $line-number-new; - border-color: $line-added-dark; - } - - &.hll:not(.empty-cell) { - background-color: $line-number-select; - border-color: $line-select-yellow-dark; + border-right-color: $line-added-dark; } } .line_content { + padding-left: 0.5em; + padding-right: 0.5em; + white-space: pre; + &.old { background-color: $line-removed; - > .line > span.idiff, > .line > span > span.idiff { + > .line > span.idiff, + > .line > span > span.idiff { background-color: $line-removed-dark; } } @@ -94,7 +59,8 @@ pre.code, .diff-line-num { &.new { background-color: $line-added; - > .line > span.idiff, > .line > span > span.idiff { + > .line > span.idiff, + > .line > span > span.idiff { background-color: $line-added-dark; } } @@ -103,14 +69,6 @@ pre.code, .diff-line-num { color: $black-transparent; background-color: $match-line; } - - &.hll:not(.empty-cell) { - background-color: $line-select-yellow; - } -} - -pre > .hll { - background-color: #f8eec7 !important; } span.highlight_word { diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 0298577c494..6a58b445afa 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -1,8 +1,6 @@ .commits-compare-switch { @include btn-default; @include btn-white; - background: image-url("switch_icon.png") no-repeat center center; - text-indent: -9999px; float: left; margin-right: 9px; } @@ -61,6 +59,10 @@ font-size: 0; } + .ci-status-link { + display: inline-block; + } + .btn-clipboard, .btn-transparent { padding-left: 0; padding-right: 0; diff --git a/app/assets/stylesheets/pages/dashboard.scss b/app/assets/stylesheets/pages/dashboard.scss index cf7567513ec..42928ee279c 100644 --- a/app/assets/stylesheets/pages/dashboard.scss +++ b/app/assets/stylesheets/pages/dashboard.scss @@ -36,10 +36,6 @@ .dash-project-avatar { float: left; - - .avatar { - @include border-radius(50%); - } } .dash-project-access-icon { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 21b1c223c88..21cee2e3a70 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -164,7 +164,10 @@ line-height: 0; img { border: 1px solid #fff; - background: image-url('trans_bg.gif'); + background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5 100%), + linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5 100%); + background-size: 10px 10px; + background-position: 0 0, 5px 5px; max-width: 100%; } &.deleted { diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 2a3acc3eb4c..b657ca47d38 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -23,15 +23,9 @@ } .group-row { - &.no-description { - .group-name { - line-height: 44px; - } - } - .stats { float: right; - line-height: 44px; + line-height: $list-text-height; color: $gl-gray; span { diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index ee3b2d2b801..dfe1e3075da 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -99,3 +99,33 @@ form.edit-issue { .issue-form .select2-container { width: 250px !important; } + +.issues-footer { + padding-top: $gl-padding; + padding-bottom: 37px; +} + +.issue-email-modal-btn { + padding: 0; + color: $gl-link-color; + background-color: transparent; + border: 0; + outline: 0; + + &:hover { + text-decoration: underline; + } +} + +.email-modal-input-group { + margin-bottom: 10px; + + .form-control { + background-color: $white-light; + } + + .btn { + background-color: $background-color; + border: 1px solid $border-gray-light; + } +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index db295935b00..0a661e529f0 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -216,6 +216,11 @@ position: relative; top: 3px; } + + &:hover, + &:focus { + text-decoration: none; + } } } } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index c58e2ffe7f5..21919fe4d73 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -18,6 +18,10 @@ .btn { margin: 4px; } + + .table.builds { + min-width: 1200px; + } } .content-list { @@ -35,7 +39,7 @@ } .table.builds { - min-width: 1200px; + min-width: 900px; &.pipeline { min-width: 650px; @@ -128,7 +132,7 @@ .icon-container { display: inline-block; text-align: right; - width: 20px; + width: 15px; .fa { position: relative; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index cc3aef5199e..cf9aa02600d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -512,18 +512,12 @@ pre.light-well { .project-row { border-color: $table-border-color; - &.no-description { - .project { - line-height: 40px; - } - } - .project-full-name { @include str-truncated; } .controls { - line-height: 40px; + line-height: $list-text-height; a:hover { text-decoration: none; @@ -661,14 +655,39 @@ pre.light-well { } } +.new_protected_branch { + label { + margin-top: 6px; + font-weight: normal; + } +} + .protected-branches-list { a { color: $gl-gray; - font-weight: 600; &:hover { color: $gl-link-color; } + + &.is-active { + font-weight: 600; + } + } + + .settings-message { + margin: 0; + border-radius: 0 0 1px 1px; + padding: 20px 0; + border: none; + } + + .table-bordered { + border-radius: 1px; + + th:not(:last-child), td:not(:last-child) { + border-right: solid 1px transparent; + } } } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 390977297fb..9da40fe2b09 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -58,6 +58,10 @@ .tree_commit { max-width: 320px; + + .str-truncated { + max-width: 100%; + } } .tree_time_ago { diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb new file mode 100644 index 00000000000..a478176e138 --- /dev/null +++ b/app/controllers/admin/requests_profiles_controller.rb @@ -0,0 +1,17 @@ +class Admin::RequestsProfilesController < Admin::ApplicationController + def index + @profile_token = Gitlab::RequestProfiler.profile_token + @profiles = Gitlab::RequestProfiler::Profile.all.group_by(&:request_path) + end + + def show + clean_name = Rack::Utils.clean_path_info(params[:name]) + profile = Gitlab::RequestProfiler::Profile.find(clean_name) + + if profile + render text: profile.content + else + redirect_to admin_requests_profiles_path, alert: 'Profile not found' + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a1004d9bcea..634d36a4467 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -243,42 +243,6 @@ class ApplicationController < ActionController::Base end end - def set_filters_params - set_default_sort - - params[:scope] = 'all' if params[:scope].blank? - params[:state] = 'opened' if params[:state].blank? - - @sort = params[:sort] - @filter_params = params.dup - - if @project - @filter_params[:project_id] = @project.id - elsif @group - @filter_params[:group_id] = @group.id - else - # TODO: this filter ignore issues/mr created in public or - # internal repos where you are not a member. Enable this filter - # or improve current implementation to filter only issues you - # created or assigned or mentioned - # @filter_params[:authorized_only] = true - end - - @filter_params - end - - def get_issues_collection - set_filters_params - @issuable_finder = IssuesFinder.new(current_user, @filter_params) - @issuable_finder.execute - end - - def get_merge_requests_collection - set_filters_params - @issuable_finder = MergeRequestsFinder.new(current_user, @filter_params) - @issuable_finder.execute - end - def import_sources_enabled? !current_application_settings.import_sources.empty? end @@ -363,24 +327,4 @@ class ApplicationController < ActionController::Base def u2f_app_id request.base_url end - - private - - def set_default_sort - key = if is_a_listing_page_for?('issues') || is_a_listing_page_for?('merge_requests') - 'issuable_sort' - end - - cookies[key] = params[:sort] if key && params[:sort].present? - params[:sort] = cookies[key] if key - params[:sort] ||= 'id_desc' - end - - def is_a_listing_page_for?(page_type) - controller_name, action_name = params.values_at(:controller, :action) - - (controller_name == "projects/#{page_type}" && action_name == 'index') || - (controller_name == 'groups' && action_name == page_type) || - (controller_name == 'dashboard' && action_name == page_type) - end end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index c89678cf2d8..d828d163c28 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -5,6 +5,7 @@ class AutocompleteController < ApplicationController def users @users ||= User.none @users = @users.search(params[:search]) if params[:search].present? + @users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present? @users = @users.active @users = @users.reorder(:name) @users = @users.page(params[:page]) diff --git a/app/controllers/concerns/diff_for_path.rb b/app/controllers/concerns/diff_for_path.rb index 026d8b2e1e0..aeec3009f15 100644 --- a/app/controllers/concerns/diff_for_path.rb +++ b/app/controllers/concerns/diff_for_path.rb @@ -1,8 +1,8 @@ module DiffForPath extend ActiveSupport::Concern - def render_diff_for_path(diffs, diff_refs, project) - diff_file = safe_diff_files(diffs, diff_refs: diff_refs, repository: project.repository).find do |diff| + def render_diff_for_path(diffs) + diff_file = diffs.diff_files.find do |diff| diff.old_path == params[:old_path] && diff.new_path == params[:new_path] end @@ -14,7 +14,7 @@ module DiffForPath locals = { diff_file: diff_file, diff_commit: diff_commit, - diff_refs: diff_refs, + diff_refs: diffs.diff_refs, blob: blob, project: project } diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb new file mode 100644 index 00000000000..c802922e0af --- /dev/null +++ b/app/controllers/concerns/issuable_collections.rb @@ -0,0 +1,79 @@ +module IssuableCollections + extend ActiveSupport::Concern + include SortingHelper + + included do + helper_method :issues_finder + helper_method :merge_requests_finder + end + + private + + def issues_collection + issues_finder.execute + end + + def merge_requests_collection + merge_requests_finder.execute + end + + def issues_finder + @issues_finder ||= issuable_finder_for(IssuesFinder) + end + + def merge_requests_finder + @merge_requests_finder ||= issuable_finder_for(MergeRequestsFinder) + end + + def issuable_finder_for(finder_class) + finder_class.new(current_user, filter_params) + end + + def filter_params + set_sort_order_from_cookie + set_default_scope + set_default_state + + @filter_params = params.dup + @filter_params[:sort] ||= default_sort_order + + @sort = @filter_params[:sort] + + if @project + @filter_params[:project_id] = @project.id + elsif @group + @filter_params[:group_id] = @group.id + else + # TODO: this filter ignore issues/mr created in public or + # internal repos where you are not a member. Enable this filter + # or improve current implementation to filter only issues you + # created or assigned or mentioned + # @filter_params[:authorized_only] = true + end + + @filter_params + end + + def set_default_scope + params[:scope] = 'all' if params[:scope].blank? + end + + def set_default_state + params[:state] = 'opened' if params[:state].blank? + end + + def set_sort_order_from_cookie + key = 'issuable_sort' + + cookies[key] = params[:sort] if params[:sort].present? + params[:sort] = cookies[key] + end + + def default_sort_order + case params[:state] + when 'opened', 'all' then sort_value_recently_created + when 'merged', 'closed' then sort_value_recently_updated + else sort_value_recently_created + end + end +end diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issues_action.rb index 4feabc32b1c..b89fb94be6e 100644 --- a/app/controllers/concerns/issues_action.rb +++ b/app/controllers/concerns/issues_action.rb @@ -1,12 +1,14 @@ module IssuesAction extend ActiveSupport::Concern + include IssuableCollections def issues - @issues = get_issues_collection.non_archived - @issues = @issues.page(params[:page]) - @issues = @issues.preload(:author, :project) + @label = issues_finder.labels.first - @label = @issuable_finder.labels.first + @issues = issues_collection + .non_archived + .preload(:author, :project) + .page(params[:page]) respond_to do |format| format.html diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb index 06a6b065e7e..a1b0eee37f9 100644 --- a/app/controllers/concerns/merge_requests_action.rb +++ b/app/controllers/concerns/merge_requests_action.rb @@ -1,11 +1,13 @@ module MergeRequestsAction extend ActiveSupport::Concern + include IssuableCollections def merge_requests - @merge_requests = get_merge_requests_collection.non_archived - @merge_requests = @merge_requests.page(params[:page]) - @merge_requests = @merge_requests.preload(:author, :target_project) + @label = merge_requests_finder.labels.first - @label = @issuable_finder.labels.first + @merge_requests = merge_requests_collection + .non_archived + .preload(:author, :target_project) + .page(params[:page]) end end diff --git a/app/controllers/explore/application_controller.rb b/app/controllers/explore/application_controller.rb index 461fc059a3c..a1ab8b99048 100644 --- a/app/controllers/explore/application_controller.rb +++ b/app/controllers/explore/application_controller.rb @@ -1,5 +1,5 @@ class Explore::ApplicationController < ApplicationController - skip_before_action :authenticate_user!, :reject_blocked + skip_before_action :authenticate_user!, :reject_blocked! layout 'explore' end diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index f7b44099b78..4eca278599f 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -1,5 +1,5 @@ class HelpController < ApplicationController - skip_before_action :authenticate_user!, :reject_blocked + skip_before_action :authenticate_user!, :reject_blocked! layout 'help' diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 25e58724860..944c73d139a 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -82,8 +82,6 @@ class Import::BitbucketController < Import::BaseController go_to_bitbucket_for_permissions end - private - def access_params { bitbucket_access_token: session[:bitbucket_access_token], diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb index 23a396e8084..08130ee8176 100644 --- a/app/controllers/import/gitlab_controller.rb +++ b/app/controllers/import/gitlab_controller.rb @@ -61,8 +61,6 @@ class Import::GitlabController < Import::BaseController go_to_gitlab_for_permissions end - private - def access_params { gitlab_access_token: session[:gitlab_access_token] } end diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb index 30df1fb2fec..3ec173abcdb 100644 --- a/app/controllers/import/gitlab_projects_controller.rb +++ b/app/controllers/import/gitlab_projects_controller.rb @@ -12,13 +12,14 @@ class Import::GitlabProjectsController < Import::BaseController return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive." }) end - imported_file = project_params[:file].path + "-import" + import_upload_path = Gitlab::ImportExport.import_upload_path(filename: project_params[:file].original_filename) - FileUtils.copy_entry(project_params[:file].path, imported_file) + FileUtils.mkdir_p(File.dirname(import_upload_path)) + FileUtils.copy_entry(project_params[:file].path, import_upload_path) @project = Gitlab::ImportExport::ProjectCreator.new(project_params[:namespace_id], current_user, - File.expand_path(imported_file), + import_upload_path, project_params[:path]).execute if @project.saved? diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index c780e0983f9..6217ec5ecef 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -50,6 +50,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController flash[:notice] = "Password was successfully updated. Please login with it" redirect_to new_user_session_path else + @user.reload render 'edit' end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index eda3727a28d..19d051720e9 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -76,6 +76,8 @@ class Projects::BlobController < Projects::ApplicationController end def diff + apply_diff_view_cookie! + @form = UnfoldForm.new(params) @lines = Gitlab::Highlight.highlight_lines(repository, @ref, @path) @lines = @lines[@form.since - 1..@form.to - 1] diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index dd9508da049..e926043f3eb 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -6,8 +6,8 @@ class Projects::BranchesController < Projects::ApplicationController before_action :authorize_push_code!, only: [:new, :create, :destroy] def index - @sort = params[:sort] || 'name' - @branches = @repository.branches_sorted_by(@sort) + @sort = params[:sort].presence || 'name' + @branches = BranchesFinder.new(@repository, params).execute @branches = Kaminari.paginate_array(@branches).page(params[:page]) @max_commits = @branches.reduce(0) do |memo, branch| diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 7ae034f9398..fdfe7c65b7b 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -28,7 +28,7 @@ class Projects::CommitController < Projects::ApplicationController end def diff_for_path - render_diff_for_path(@diffs, @commit.diff_refs, @project) + render_diff_for_path(@commit.diffs(diff_options)) end def builds diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 8c004724f02..bee3d56076c 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -21,7 +21,7 @@ class Projects::CompareController < Projects::ApplicationController def diff_for_path return render_404 unless @compare - render_diff_for_path(@diffs, @diff_refs, @project) + render_diff_for_path(@compare.diffs(diff_options)) end def create @@ -40,18 +40,12 @@ class Projects::CompareController < Projects::ApplicationController @compare = CompareService.new.execute(@project, @head_ref, @project, @start_ref) if @compare - @commits = Commit.decorate(@compare.commits, @project) - - @start_commit = @project.commit(@start_ref) - @commit = @project.commit(@head_ref) - @base_commit = @project.merge_base_commit(@start_ref, @head_ref) + @commits = @compare.commits + @start_commit = @compare.start_commit + @commit = @compare.commit + @base_commit = @compare.base_commit @diffs = @compare.diffs(diff_options) - @diff_refs = Gitlab::Diff::DiffRefs.new( - base_sha: @base_commit.try(:sha), - start_sha: @start_commit.try(:sha), - head_sha: @commit.try(:sha) - ) @diff_notes_disabled = true @grouped_diff_discussions = {} diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 83d5ced9be8..529e0aa2d33 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -12,8 +12,7 @@ class Projects::DeployKeysController < Projects::ApplicationController end def new - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) end def create @@ -21,19 +20,16 @@ class Projects::DeployKeysController < Projects::ApplicationController set_index_vars if @key.valid? && @project.deploy_keys << @key - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) else render "index" end end def enable - @key = accessible_keys.find(params[:id]) - @project.deploy_keys << @key + Projects::EnableDeployKeyService.new(@project, current_user, params).execute - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) end def disable @@ -45,9 +41,9 @@ class Projects::DeployKeysController < Projects::ApplicationController protected def set_index_vars - @enabled_keys ||= @project.deploy_keys + @enabled_keys ||= @project.deploy_keys - @available_keys ||= accessible_keys - @enabled_keys + @available_keys ||= current_user.accessible_deploy_keys - @enabled_keys @available_project_keys ||= current_user.project_deploy_keys - @enabled_keys @available_public_keys ||= DeployKey.are_public - @enabled_keys @@ -56,10 +52,6 @@ class Projects::DeployKeysController < Projects::ApplicationController @available_public_keys -= @available_project_keys end - def accessible_keys - @accessible_keys ||= current_user.accessible_deploy_keys - end - def deploy_key_params params.require(:deploy_key).permit(:key, :title) end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 4b433796161..58678f96879 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -2,8 +2,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController layout 'project' before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] - before_action :authorize_update_environment!, only: [:destroy] - before_action :environment, only: [:show, :destroy] + before_action :authorize_update_environment!, only: [:edit, :update, :destroy] + before_action :environment, only: [:show, :edit, :update, :destroy] def index @environments = project.environments @@ -17,13 +17,24 @@ class Projects::EnvironmentsController < Projects::ApplicationController @environment = project.environments.new end + def edit + end + def create - @environment = project.environments.create(create_params) + @environment = project.environments.create(environment_params) if @environment.persisted? redirect_to namespace_project_environment_path(project.namespace, project, @environment) else - render 'new' + render :new + end + end + + def update + if @environment.update(environment_params) + redirect_to namespace_project_environment_path(project.namespace, project, @environment) + else + render :edit end end @@ -39,8 +50,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController private - def create_params - params.require(:environment).permit(:name) + def environment_params + params.require(:environment).permit(:name, :external_url) end def environment diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index be73a4c0d2c..a10dca6afaf 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -9,9 +9,9 @@ class Projects::GitHttpController < Projects::GitHttpClientController elsif receive_pack? && receive_pack_allowed? render_ok elsif http_blocked? - render_not_allowed + render_http_not_allowed else - render_not_found + render_denied end end @@ -20,7 +20,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController if upload_pack? && upload_pack_allowed? render_ok else - render_not_found + render_denied end end @@ -29,7 +29,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController if receive_pack? && receive_pack_allowed? render_ok else - render_not_found + render_denied end end @@ -59,30 +59,41 @@ class Projects::GitHttpController < Projects::GitHttpClientController render json: Gitlab::Workhorse.git_http_ok(repository, user) end - def render_not_allowed - render plain: download_access.message, status: :forbidden + def render_not_found + render plain: 'Not Found', status: :not_found + end + + def render_http_not_allowed + render plain: access_check.message, status: :forbidden + end + + def render_denied + if user && user.can?(:read_project, project) + render plain: 'Access denied', status: :forbidden + else + # Do not leak information about project existence + render_not_found + end end def upload_pack_allowed? return false unless Gitlab.config.gitlab_shell.upload_pack if user - download_access.allowed? + access_check.allowed? else ci? || project.public? end end def access - return @access if defined?(@access) - - @access = Gitlab::GitAccess.new(user, project, 'http') + @access ||= Gitlab::GitAccess.new(user, project, 'http') end - def download_access - return @download_access if defined?(@download_access) - - @download_access = access.check('git-upload-pack') + def access_check + # Use the magic string '_any' to indicate we do not know what the + # changes are. This is also what gitlab-shell does. + @access_check ||= access.check(git_command, '_any') end def http_blocked? @@ -92,8 +103,6 @@ class Projects::GitHttpController < Projects::GitHttpClientController def receive_pack_allowed? return false unless Gitlab.config.gitlab_shell.receive_pack - # Skip user authorization on upload request. - # It will be done by the pre-receive hook in the repository. - user.present? + access_check.allowed? end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index fa663c9bda4..660e0eba06f 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,8 +1,11 @@ class Projects::IssuesController < Projects::ApplicationController + include NotesHelper include ToggleSubscriptionAction include IssuableActions include ToggleAwardEmoji + include IssuableCollections + 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] @@ -23,7 +26,7 @@ class Projects::IssuesController < Projects::ApplicationController def index terms = params['issue_search'] - @issues = get_issues_collection + @issues = issues_collection if terms.present? if terms =~ /\A#(\d+)\z/ @@ -70,6 +73,8 @@ class Projects::IssuesController < Projects::ApplicationController @note = @project.notes.new(noteable: @issue) @noteable = @issue + preload_max_access_for_authors(@notes, @project) + respond_to do |format| format.html format.json do @@ -79,7 +84,7 @@ class Projects::IssuesController < Projects::ApplicationController end def create - @issue = Issues::CreateService.new(project, current_user, issue_params).execute + @issue = Issues::CreateService.new(project, current_user, issue_params.merge(request: request)).execute respond_to do |format| format.html do @@ -89,7 +94,7 @@ class Projects::IssuesController < Projects::ApplicationController render :new end end - format.js do |format| + format.js do @link = @issue.attachment.url.to_js end end @@ -197,6 +202,18 @@ class Projects::IssuesController < Projects::ApplicationController return render_404 unless @project.issues_enabled && @project.default_issues_tracker? end + def redirect_to_external_issue_tracker + external = @project.external_issue_tracker + + return unless external + + if action_name == 'new' + redirect_to external.new_issue_path + else + redirect_to external.issues_url + end + end + # Since iids are implemented only in 6.1 # user may navigate to issue page using old global ids. # diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 594a61464b9..2cf6a2dd1b3 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -3,7 +3,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController include DiffForPath include DiffHelper include IssuableActions + include NotesHelper include ToggleAwardEmoji + include IssuableCollections before_action :module_enabled before_action :merge_request, only: [ @@ -28,7 +30,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController def index terms = params['issue_search'] - @merge_requests = get_merge_requests_collection + @merge_requests = merge_requests_collection if terms.present? if terms =~ /\A[#!](\d+)\z/ @@ -83,7 +85,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController respond_to do |format| format.html { define_discussion_vars } - format.json { render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") } } + format.json do + @diffs = @merge_request.diffs(diff_options) + + render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") } + end end end @@ -101,9 +107,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController end define_commit_vars - diffs = @merge_request.diffs(diff_options) - render_diff_for_path(diffs, @merge_request.diff_refs, @merge_request.project) + render_diff_for_path(@merge_request.diffs(diff_options)) end def commits @@ -151,7 +156,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController @commits = @merge_request.compare_commits.reverse @commit = @merge_request.diff_head_commit @base_commit = @merge_request.diff_base_commit - @diffs = @merge_request.compare.diffs(diff_options) if @merge_request.compare + @diffs = @merge_request.diffs(diff_options) if @merge_request.compare @diff_notes_disabled = true @pipeline = @merge_request.pipeline @@ -376,6 +381,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController fresh. discussions + preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) + # This is not executed lazily @notes = Banzai::NoteRenderer.render( @discussions.flat_map(&:notes), @@ -385,6 +392,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController @project_wiki, @ref ) + + preload_max_access_for_authors(@notes, @project) end def define_widget_vars @@ -404,7 +413,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController } @use_legacy_diff_notes = !@merge_request.support_new_diff_notes? - @grouped_diff_discussions = @merge_request.notes.grouped_diff_discussions + @grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions Banzai::NoteRenderer.render( @grouped_diff_discussions.values.flat_map(&:notes), diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index 10dca47fded..d28ec6e2eac 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -3,19 +3,24 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController before_action :require_non_empty_project before_action :authorize_admin_project! before_action :load_protected_branch, only: [:show, :update, :destroy] + before_action :load_protected_branches, only: [:index] layout "project_settings" def index - @protected_branches = @project.protected_branches.order(:name).page(params[:page]) @protected_branch = @project.protected_branches.new - gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } }) + load_protected_branches_gon_variables end def create - @project.protected_branches.create(protected_branch_params) - redirect_to namespace_project_protected_branches_path(@project.namespace, - @project) + @protected_branch = ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute + if @protected_branch.persisted? + redirect_to namespace_project_protected_branches_path(@project.namespace, @project) + else + load_protected_branches + load_protected_branches_gon_variables + render :index + end end def show @@ -23,7 +28,9 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController end def update - if @protected_branch && @protected_branch.update_attributes(protected_branch_params) + @protected_branch = ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) + + if @protected_branch.valid? respond_to do |format| format.json { render json: @protected_branch, status: :ok } end @@ -50,6 +57,18 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController end def protected_branch_params - params.require(:protected_branch).permit(:name, :developers_can_push, :developers_can_merge) + params.require(:protected_branch).permit(:name, + merge_access_level_attributes: [:access_level], + push_access_level_attributes: [:access_level]) + end + + def load_protected_branches + @protected_branches = @project.protected_branches.order(:name).page(params[:page]) + end + + def load_protected_branches_gon_variables + gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } }, + push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } }, + merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } } }) end end diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 6dc495247c8..8592579abbd 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -10,11 +10,12 @@ class Projects::TagsController < Projects::ApplicationController @tags = @repository.tags_sorted_by(@sort) @tags = Kaminari.paginate_array(@tags).page(params[:page]) - @releases = project.releases.where(tag: @tags) + @releases = project.releases.where(tag: @tags.map(&:name)) end def show @tag = @repository.find_tag(params[:id]) + @release = @project.releases.find_or_initialize_by(tag: @tag.name) @commit = @repository.commit(@tag.target) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ec7a2e63b9a..a6e1aa5ccc1 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -97,7 +97,7 @@ class ProjectsController < Projects::ApplicationController end if @project.pending_delete? - flash[:alert] = "Project queued for delete." + flash[:alert] = "Project #{@project.name} queued for deletion." end respond_to do |format| diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 75b78a49eab..3327f4f2b87 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -33,7 +33,7 @@ class RegistrationsController < Devise::RegistrationsController protected - def build_resource(hash=nil) + def build_resource(hash = nil) super end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 69c92d2bed2..61517d21f9f 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -1,5 +1,5 @@ class SearchController < ApplicationController - skip_before_action :authenticate_user!, :reject_blocked + skip_before_action :authenticate_user!, :reject_blocked! include SearchHelper diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 17aed816cbd..5d7ecfeacf4 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -101,7 +101,7 @@ class SessionsController < Devise::SessionsController # Prevent alert from popping up on the first page shown after authentication. flash[:alert] = nil - redirect_to user_omniauth_authorize_path(provider.to_sym) + redirect_to omniauth_authorize_path(:user, provider) end def valid_otp_attempt?(user) diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb new file mode 100644 index 00000000000..533076585c0 --- /dev/null +++ b/app/finders/branches_finder.rb @@ -0,0 +1,31 @@ +class BranchesFinder + def initialize(repository, params) + @repository = repository + @params = params + end + + def execute + branches = @repository.branches_sorted_by(sort) + filter_by_name(branches) + end + + private + + attr_reader :repository, :params + + def search + @params[:search].presence + end + + def sort + @params[:sort].presence || 'name' + end + + def filter_by_name(branches) + if search + branches.select { |branch| branch.name.include?(search) } + else + branches + end + end +end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index a0932712bd0..33daac0399e 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -109,7 +109,7 @@ class IssuableFinder scope.where(title: params[:milestone_title]) else - nil + Milestone.none end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 03495cf5ec4..c3613bc67dd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -163,9 +163,13 @@ module ApplicationHelper # `html_class` argument is provided. # # Returns an HTML-safe String - def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago', skip_js: false) + def time_ago_with_tooltip(time, placement: 'top', html_class: '', skip_js: false, short_format: false) + css_classes = short_format ? 'js-short-timeago' : 'js-timeago' + css_classes << " #{html_class}" unless html_class.blank? + css_classes << ' js-timeago-pending' unless skip_js + element = content_tag :time, time.to_s, - class: "#{html_class} js-timeago #{"js-timeago-pending" unless skip_js}", + class: css_classes, datetime: time.to_time.getutc.iso8601, title: time.to_time.in_time_zone.to_s(:medium), data: { toggle: 'tooltip', placement: placement, container: 'body' } @@ -245,7 +249,6 @@ module ApplicationHelper milestone_title: params[:milestone_title], assignee_id: params[:assignee_id], author_id: params[:author_id], - sort: params[:sort], issue_search: params[:issue_search], label_name: params[:label_name] } diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 6ff40c6b461..2160cf7a690 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -1,5 +1,4 @@ module AvatarsHelper - def author_avatar(commit_or_event, options = {}) user_avatar(options.merge({ user: commit_or_event.author, @@ -26,5 +25,4 @@ module AvatarsHelper mail_to(options[:user_email], avatar) end end - end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index abe115d8c68..48c27828219 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -13,7 +13,7 @@ module BlobHelper blob = project.repository.blob_at(ref, path) rescue nil - return unless blob && blob_text_viewable?(blob) + return unless blob from_mr = options[:from_merge_request_id] link_opts = {} diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index bfd23aa4e04..3fc85dc6b2b 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -9,6 +9,17 @@ module BranchesHelper end end + def filter_branches_path(options = {}) + exist_opts = { + search: params[:search], + sort: params[:sort] + } + + options = exist_opts.merge(options) + + namespace_project_branches_path(@project.namespace, @project, @id, options) + end + def can_push_branch?(project, branch_name) return false unless project.repository.branch_exists?(branch_name) diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 052ce56809e..7a02d0b10d9 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -1,4 +1,3 @@ -# encoding: utf-8 module CommitsHelper # Returns a link to the commit author. If the author has a matching user and # is a member of the current @project it will link to the team member page. @@ -207,10 +206,10 @@ module CommitsHelper end end - def view_file_btn(commit_sha, diff, project) + def view_file_btn(commit_sha, diff_new_path, project) link_to( namespace_project_blob_path(project.namespace, project, - tree_join(commit_sha, diff.new_path)), + tree_join(commit_sha, diff_new_path)), class: 'btn view-file js-view-file btn-file-option' ) do raw('View file @') + content_tag(:span, commit_sha[0..6], diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 4c031942793..f3c9ea074b4 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -13,12 +13,11 @@ module DiffHelper end def diff_view - diff_views = %w(inline parallel) - - if diff_views.include?(cookies[:diff_view]) - cookies[:diff_view] - else - diff_views.first + @diff_view ||= begin + diff_views = %w(inline parallel) + diff_view = cookies[:diff_view] + diff_view = diff_views.first unless diff_views.include?(diff_view) + diff_view.to_sym end end @@ -30,19 +29,26 @@ module DiffHelper options[:paths] = params.values_at(:old_path, :new_path) end - Commit.max_diff_options.merge(options) + options end - def safe_diff_files(diffs, diff_refs: nil, repository: nil) - diffs.decorate! { |diff| Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } - end + def diff_match_line(old_pos, new_pos, text: '', view: :inline, bottom: false) + content = content_tag :td, text, class: "line_content match #{view == :inline ? '' : view}" + cls = ['diff-line-num', 'unfold', 'js-unfold'] + cls << 'js-unfold-bottom' if bottom - def unfold_bottom_class(bottom) - bottom ? 'js-unfold js-unfold-bottom' : '' - end + html = '' + if old_pos + html << content_tag(:td, '...', class: cls + ['old_line'], data: { linenumber: old_pos }) + html << content unless view == :inline + end - def unfold_class(unfold) - unfold ? 'unfold js-unfold' : '' + if new_pos + html << content_tag(:td, '...', class: cls + ['new_line'], data: { linenumber: new_pos }) + html << content + end + + html.html_safe end def diff_line_content(line, line_type = nil) @@ -71,11 +77,11 @@ module DiffHelper end def inline_diff_btn - diff_btn('Inline', 'inline', diff_view == 'inline') + diff_btn('Inline', 'inline', diff_view == :inline) end def parallel_diff_btn - diff_btn('Side-by-side', 'parallel', diff_view == 'parallel') + diff_btn('Side-by-side', 'parallel', diff_view == :parallel) end def submodule_link(blob, ref, repository = @repository) @@ -107,7 +113,8 @@ module DiffHelper commit = commit_for_diff(diff_file) { blob_diff_path: namespace_project_blob_diff_path(project.namespace, project, - tree_join(commit.id, diff_file.file_path)) + tree_join(commit.id, diff_file.file_path)), + view: diff_view } end @@ -144,8 +151,6 @@ module DiffHelper toggle_whitespace_link(url, options) end - private - def hide_whitespace? params[:w] == '1' end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 337b0aacbb5..2b1f3825adc 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -1,5 +1,5 @@ module ExploreHelper - def filter_projects_path(options={}) + def filter_projects_path(options = {}) exist_opts = { sort: params[:sort], scope: params[:scope], diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 2b0defd1dda..2e82b44437b 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -13,38 +13,6 @@ module IssuesHelper OpenStruct.new(id: 0, title: 'None (backlog)', name: 'Unassigned') end - def url_for_project_issues(project = @project, options = {}) - return '' if project.nil? - - url = - if options[:only_path] - project.issues_tracker.project_path - else - project.issues_tracker.project_url - end - - # Ensure we return a valid URL to prevent possible XSS. - URI.parse(url).to_s - rescue URI::InvalidURIError - '' - end - - def url_for_new_issue(project = @project, options = {}) - return '' if project.nil? - - url = - if options[:only_path] - project.issues_tracker.new_issue_path - else - project.issues_tracker.new_issue_url - end - - # Ensure we return a valid URL to prevent possible XSS. - URI.parse(url).to_s - rescue URI::InvalidURIError - '' - end - def url_for_issue(issue_iid, project = @project, options = {}) return '' if project.nil? diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 0f60dd828ab..26bde2230a9 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -7,7 +7,7 @@ module NotesHelper end def note_editable?(note) - note.editable? && can?(current_user, :admin_note, note) + Ability.can_edit_note?(current_user, note) end def noteable_json(noteable) @@ -87,14 +87,17 @@ module NotesHelper end end - def note_max_access_for_user(note) - @max_access_by_user_id ||= Hash.new do |hash, key| - project = key[:project] - hash[key] = project.team.human_max_access(key[:user_id]) - end + def preload_max_access_for_authors(notes, project) + user_ids = notes.map(&:author_id) + project.team.max_member_access_for_user_ids(user_ids) + end - full_key = { project: note.project, user_id: note.author_id } - @max_access_by_user_id[full_key] + def preload_noteable_for_regular_notes(notes) + ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable) + end + + def note_max_access_for_user(note) + note.project.team.human_max_access(note.author_id) end def discussion_diff_path(discussion) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index a733dff1579..505545fbabb 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -263,6 +263,10 @@ module ProjectsHelper filename_path(project, :version) end + def ci_configuration_path(project) + filename_path(project, :gitlab_ci_yml) + end + def project_wiki_path_with_version(proj, page, version, is_newest) url_params = is_newest ? {} : { version_id: version } namespace_project_wiki_path(proj.namespace, proj, page, url_params) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index fcb2703e837..c0195713f4a 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -107,12 +107,13 @@ module SearchHelper Sanitize.clean(str) end - def search_filter_path(options={}) + def search_filter_path(options = {}) exist_opts = { search: params[:search], project_id: params[:project_id], group_id: params[:group_id], - scope: params[:scope] + scope: params[:scope], + repository_ref: params[:repository_ref] } options = exist_opts.merge(options) diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index bb395e37884..5f27e33c6ad 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -5,21 +5,9 @@ module SelectsHelper css_class << "skip_ldap " if opts[:skip_ldap] css_class << (opts[:class] || '') value = opts[:selected] || '' - - first_user = opts[:first_user] && current_user ? current_user.username : false - html = { class: css_class, - data: { - placeholder: opts[:placeholder] || 'Search for a user', - null_user: opts[:null_user] || false, - any_user: opts[:any_user] || false, - email_user: opts[:email_user] || false, - first_user: first_user, - current_user: opts[:current_user] || false, - "push-code-to-protected-branches" => opts[:push_code_to_protected_branches], - author_id: opts[:author_id] || '' - } + data: users_select_data_attributes(opts) } unless opts[:scope] == :all @@ -68,4 +56,20 @@ module SelectsHelper hidden_field_tag(id, value, class: css_class) end + + private + + def users_select_data_attributes(opts) + { + placeholder: opts[:placeholder] || 'Search for a user', + null_user: opts[:null_user] || false, + any_user: opts[:any_user] || false, + email_user: opts[:email_user] || false, + first_user: opts[:first_user] && current_user ? current_user.username : false, + current_user: opts[:current_user] || false, + "push-code-to-protected-branches" => opts[:push_code_to_protected_branches], + author_id: opts[:author_id] || '', + skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil, + } + end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index d86f1999f5c..e1c0b497550 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -102,11 +102,11 @@ module SortingHelper end def sort_value_oldest_created - 'id_asc' + 'created_asc' end def sort_value_recently_created - 'id_desc' + 'created_desc' end def sort_value_milestone_soon diff --git a/app/models/ability.rb b/app/models/ability.rb index f33c8d61d3f..d9113ffd99a 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -6,6 +6,10 @@ class Ability return [] unless user.is_a?(User) return [] if user.blocked? + abilities_by_subject_class(user: user, subject: subject) + end + + def abilities_by_subject_class(user:, subject:) case subject when CommitStatus then commit_status_abilities(user, subject) when Project then project_abilities(user, subject) @@ -47,6 +51,16 @@ class Ability end end + # Returns an Array of Issues that can be read by the given user. + # + # issues - The issues to reduce down to those readable by the user. + # user - The User for which to check the issues + def issues_readable_by_user(issues, user = nil) + return issues if user && user.admin? + + issues.select { |issue| issue.visible_to_user?(user) } + end + # List of possible abilities for anonymous user def anonymous_abilities(user, subject) if subject.is_a?(PersonalSnippet) @@ -388,6 +402,18 @@ class Ability GroupProjectsFinder.new(group).execute(user).any? end + def can_edit_note?(user, note) + return false if !note.editable? || !user.present? + return true if note.author == user || user.admin? + + if note.project + max_access_level = note.project.team.max_member_access(user.id) + max_access_level >= Gitlab::Access::MASTER + else + false + end + end + def namespace_abilities(user, namespace) rules = [] diff --git a/app/models/blob.rb b/app/models/blob.rb index 4279ea2ce57..0df2805e448 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -31,6 +31,10 @@ class Blob < SimpleDelegator text? && language && language.name == 'SVG' end + def video? + UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) + end + def to_partial_path if lfs_pointer? 'download' diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index cbfa14e81f1..08f396210c9 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -13,6 +13,7 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } scope :with_artifacts, ->() { where.not(artifacts_file: [nil, '']) } + scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, ->() { where(when: :manual) } @@ -331,7 +332,7 @@ module Ci end def valid_token?(token) - project.valid_runners_token? token + project.valid_runners_token?(token) end def has_tags? diff --git a/app/models/commit.rb b/app/models/commit.rb index 2ef3973c160..cc413448ce8 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -104,7 +104,7 @@ class Commit end def diff_line_count - @diff_line_count ||= Commit::diff_line_count(self.diffs) + @diff_line_count ||= Commit::diff_line_count(raw_diffs) @diff_line_count end @@ -123,15 +123,17 @@ class Commit # In case this first line is longer than 100 characters, it is cut off # after 80 characters and ellipses (`&hellp;`) are appended. def title - title = safe_message + full_title.length > 100 ? full_title[0..79] << "…" : full_title + end - return no_commit_message if title.blank? + # Returns the full commits title + def full_title + return @full_title if @full_title - title_end = title.index("\n") - if (!title_end && title.length > 100) || (title_end && title_end > 100) - title[0..79] << "…" + if safe_message.blank? + @full_title = no_commit_message else - title.split("\n", 2).first + @full_title = safe_message.split("\n", 2).first end end @@ -178,7 +180,18 @@ class Commit end def author - @author ||= User.find_by_any_email(author_email.downcase) + if RequestStore.active? + key = "commit_author:#{author_email.downcase}" + # nil is a valid value since no author may exist in the system + if RequestStore.store.has_key?(key) + @author = RequestStore.store[key] + else + @author = find_author_by_any_email + RequestStore.store[key] = @author + end + else + @author ||= find_author_by_any_email + end end def committer @@ -295,8 +308,8 @@ class Commit def uri_type(path) entry = @raw.tree.path(path) if entry[:type] == :blob - blob = Gitlab::Git::Blob.new(name: entry[:name]) - blob.image? ? :raw : :blob + blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name])) + blob.image? || blob.video? ? :raw : :blob else entry[:type] end @@ -304,12 +317,24 @@ class Commit nil end + def raw_diffs(*args) + raw.diffs(*args) + end + + def diffs(diff_options = nil) + Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options) + end + private + def find_author_by_any_email + User.find_by_any_email(author_email.downcase) + end + def repo_changes changes = { added: [], modified: [], removed: [] } - diffs.each do |diff| + raw_diffs(deltas_only: true).each do |diff| if diff.deleted_file changes[:removed] << diff.old_path elsif diff.renamed_file || diff.new_file diff --git a/app/models/compare.rb b/app/models/compare.rb new file mode 100644 index 00000000000..4856510f526 --- /dev/null +++ b/app/models/compare.rb @@ -0,0 +1,66 @@ +class Compare + delegate :same, :head, :base, to: :@compare + + attr_reader :project + + def self.decorate(compare, project) + if compare.is_a?(Compare) + compare + else + self.new(compare, project) + end + end + + def initialize(compare, project) + @compare = compare + @project = project + end + + def commits + @commits ||= Commit.decorate(@compare.commits, project) + end + + def start_commit + return @start_commit if defined?(@start_commit) + + commit = @compare.base + @start_commit = commit ? ::Commit.new(commit, project) : nil + end + + def head_commit + return @head_commit if defined?(@head_commit) + + commit = @compare.head + @head_commit = commit ? ::Commit.new(commit, project) : nil + end + alias_method :commit, :head_commit + + def base_commit + return @base_commit if defined?(@base_commit) + + @base_commit = if start_commit && head_commit + project.merge_base_commit(start_commit.id, head_commit.id) + else + nil + end + end + + def raw_diffs(*args) + @compare.diffs(*args) + end + + def diffs(diff_options = nil) + Gitlab::Diff::FileCollection::Compare.new(self, + project: project, + diff_options: diff_options, + diff_refs: diff_refs) + end + + def diff_refs + Gitlab::Diff::DiffRefs.new( + base_sha: base_commit.try(:sha), + start_sha: start_commit.try(:sha), + head_sha: commit.try(:sha) + ) + end +end diff --git a/app/models/concerns/faster_cache_keys.rb b/app/models/concerns/faster_cache_keys.rb new file mode 100644 index 00000000000..5b14723fa2d --- /dev/null +++ b/app/models/concerns/faster_cache_keys.rb @@ -0,0 +1,16 @@ +module FasterCacheKeys + # A faster version of Rails' "cache_key" method. + # + # Rails' default "cache_key" method uses all kind of complex logic to figure + # out the cache key. In many cases this complexity and overhead may not be + # needed. + # + # This method does not do any timestamp parsing as this process is quite + # expensive and not needed when generating cache keys. This method also relies + # on the table name instead of the cache namespace name as the latter uses + # complex logic to generate the exact same value (as when using the table + # name) in 99% of the cases. + def cache_key + "#{self.class.table_name}/#{id}-#{read_attribute_before_type_cast(:updated_at)}" + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index acb6f5a2998..cbae1cd439b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -17,7 +17,7 @@ module Issuable belongs_to :assignee, class_name: "User" belongs_to :updated_by, class_name: "User" belongs_to :milestone - has_many :notes, as: :noteable, dependent: :destroy do + has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do def authors_loaded? # We check first if we're loaded to not load unnecessarily. loaded? && to_a.all? { |note| note.association(:author).loaded? } diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb new file mode 100644 index 00000000000..3b8e6df2da9 --- /dev/null +++ b/app/models/concerns/spammable.rb @@ -0,0 +1,16 @@ +module Spammable + extend ActiveSupport::Concern + + included do + attr_accessor :spam + after_validation :check_for_spam, on: :create + end + + def spam? + @spam + end + + def check_for_spam + self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? + end +end diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index 885deaf78d2..24c7b26d223 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -1,12 +1,26 @@ module TokenAuthenticatable extend ActiveSupport::Concern + private + + def write_new_token(token_field) + new_token = generate_token(token_field) + write_attribute(token_field, new_token) + end + + def generate_token(token_field) + loop do + token = Devise.friendly_token + break token unless self.class.unscoped.find_by(token_field => token) + end + end + class_methods do def authentication_token_fields @token_fields || [] end - private + private # rubocop:disable Lint/UselessAccessModifier def add_authentication_token_field(token_field) @token_fields = [] unless @token_fields @@ -32,18 +46,4 @@ module TokenAuthenticatable end end end - - private - - def write_new_token(token_field) - new_token = generate_token(token_field) - write_attribute(token_field, new_token) - end - - def generate_token(token_field) - loop do - token = Devise.friendly_token - break token unless self.class.unscoped.find_by(token_field => token) - end - end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 9671955db36..c816deb4e0c 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -67,7 +67,7 @@ class DiffNote < Note return false unless supported? return true if for_commit? - diff_refs ||= self.noteable.diff_refs + diff_refs ||= noteable_diff_refs self.position.diff_refs == diff_refs end @@ -78,6 +78,14 @@ class DiffNote < Note !self.for_merge_request? || self.noteable.support_new_diff_notes? end + def noteable_diff_refs + if noteable.respond_to?(:diff_sha_refs) + noteable.diff_sha_refs + else + noteable.diff_refs + end + end + def set_original_position self.original_position = self.position.dup end @@ -96,7 +104,7 @@ class DiffNote < Note self.project, nil, old_diff_refs: self.position.diff_refs, - new_diff_refs: self.noteable.diff_refs, + new_diff_refs: noteable_diff_refs, paths: self.position.paths ).execute(self) end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 74facfd1c9c..e2218a5f02b 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -49,6 +49,12 @@ class Discussion self.noteable == target && !diff_discussion? end + def active? + return @active if defined?(@active) + + @active = first_note.active? + end + def expanded? !diff_discussion? || active? end diff --git a/app/models/environment.rb b/app/models/environment.rb index ac3a571a1f3..baed106e8c8 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -3,6 +3,8 @@ class Environment < ActiveRecord::Base has_many :deployments + before_validation :nullify_external_url + validates :name, presence: true, uniqueness: { scope: :project_id }, @@ -10,7 +12,17 @@ class Environment < ActiveRecord::Base format: { with: Gitlab::Regex.environment_name_regex, message: Gitlab::Regex.environment_name_regex_message } + validates :external_url, + uniqueness: { scope: :project_id }, + length: { maximum: 255 }, + allow_nil: true, + addressable_url: true + def last_deployment deployments.last end + + def nullify_external_url + self.external_url = nil if self.external_url.blank? + end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 60af8c15340..d62ffb21467 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -6,6 +6,8 @@ class Issue < ActiveRecord::Base include Referable include Sortable include Taskable + include Spammable + include FasterCacheKeys DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -229,6 +231,34 @@ class Issue < ActiveRecord::Base self.closed_by_merge_requests(current_user).empty? end + # Returns `true` if the current issue can be viewed by either a logged in User + # or an anonymous user. + def visible_to_user?(user = nil) + user ? readable_by?(user) : publicly_visible? + end + + # Returns `true` if the given User can read the current Issue. + def readable_by?(user) + if user.admin? + true + elsif project.owner == user + true + elsif confidential? + author == user || + assignee == user || + project.team.member?(user, Gitlab::Access::REPORTER) + else + project.public? || + project.internal? && !user.external? || + project.team.member?(user) + end + end + + # Returns `true` if this Issue is visible to everybody. + def publicly_visible? + project.public? && !confidential? + end + def overdue? due_date.try(:past?) || false end diff --git a/app/models/key.rb b/app/models/key.rb index b9bc38a0436..568a60b8af3 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -26,8 +26,9 @@ class Key < ActiveRecord::Base end def publishable_key - # Removes anything beyond the keytype and key itself - self.key.split[0..1].join(' ') + # Strip out the keys comment so we don't leak email addresses + # Replace with simple ident of user_name (hostname) + self.key.split[0..1].push("#{self.user_name} (#{Gitlab.config.gitlab.host})").join(' ') end # projects that has this key diff --git a/app/models/label_link.rb b/app/models/label_link.rb index 47bd6eaf35f..51b5c2b1f4c 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -1,7 +1,9 @@ class LabelLink < ActiveRecord::Base + include Importable + belongs_to :target, polymorphic: true belongs_to :label - validates :target, presence: true - validates :label, presence: true + validates :target, presence: true, unless: :importing? + validates :label, presence: true, unless: :importing? end diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 04a651d50ab..6ed66001513 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -25,6 +25,14 @@ class LegacyDiffNote < Note @discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code) end + def project_repository + if RequestStore.active? + RequestStore.fetch("project:#{project_id}:repository") { self.project.repository } + else + self.project.repository + end + end + def diff_file_hash line_code.split('_')[0] if line_code end @@ -34,7 +42,7 @@ class LegacyDiffNote < Note end def diff_file - @diff_file ||= Gitlab::Diff::File.new(diff, repository: self.project.repository) if diff + @diff_file ||= Gitlab::Diff::File.new(diff, repository: project_repository) if diff end def diff_line @@ -77,7 +85,7 @@ class LegacyDiffNote < Note return nil unless noteable return @diff if defined?(@diff) - @diff = noteable.diffs(Commit.max_diff_options).find do |d| + @diff = noteable.raw_diffs(Commit.max_diff_options).find do |d| d.new_path && Digest::SHA1.hexdigest(d.new_path) == diff_file_hash end end @@ -108,7 +116,7 @@ class LegacyDiffNote < Note # Find the diff on noteable that matches our own def find_noteable_diff - diffs = noteable.diffs(Commit.max_diff_options) + diffs = noteable.raw_diffs(Commit.max_diff_options) diffs.find { |d| d.new_path == self.diff.new_path } end end diff --git a/app/models/member.rb b/app/models/member.rb index 44db3d977fa..24ab1276ee9 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -53,6 +53,10 @@ class Member < ActiveRecord::Base default_value_for :notification_level, NotificationSetting.levels[:global] class << self + def access_for_user_ids(user_ids) + where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h + end + def find_by_invite_token(invite_token) invite_token = Devise.token_generator.digest(self, :invite_token, invite_token) find_by(invite_token: invite_token) diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index f39afc61ce9..f176feddbad 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -21,19 +21,19 @@ class ProjectMember < Member # or symbol like :master representing role # # Ex. - # add_users_into_projects( + # add_users_to_projects( # project_ids, # user_ids, # ProjectMember::MASTER # ) # - # add_users_into_projects( + # add_users_to_projects( # project_ids, # user_ids, # :master # ) # - def add_users_into_projects(project_ids, user_ids, access, current_user = nil) + def add_users_to_projects(project_ids, user_ids, access, current_user = nil) access_level = if roles_hash.has_key?(access) roles_hash[access] elsif roles_hash.values.include?(access.to_i) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 471e32f3b60..b1fb3ce5d69 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -164,8 +164,16 @@ class MergeRequest < ActiveRecord::Base merge_request_diff ? merge_request_diff.first_commit : compare_commits.first end - def diffs(*args) - merge_request_diff ? merge_request_diff.diffs(*args) : compare.diffs(*args) + def raw_diffs(*args) + merge_request_diff ? merge_request_diff.raw_diffs(*args) : compare.raw_diffs(*args) + end + + def diffs(diff_options = nil) + if self.compare + self.compare.diffs(diff_options) + else + Gitlab::Diff::FileCollection::MergeRequest.new(self, diff_options: diff_options) + end end def diff_size @@ -238,11 +246,11 @@ class MergeRequest < ActiveRecord::Base end def target_branch_sha - target_branch_head.try(:sha) + @target_branch_sha || target_branch_head.try(:sha) end def source_branch_sha - source_branch_head.try(:sha) + @source_branch_sha || source_branch_head.try(:sha) end def diff_refs @@ -255,6 +263,19 @@ class MergeRequest < ActiveRecord::Base ) end + # Return diff_refs instance trying to not touch the git repository + def diff_sha_refs + if merge_request_diff && merge_request_diff.diff_refs_by_sha? + return Gitlab::Diff::DiffRefs.new( + base_sha: merge_request_diff.base_commit_sha, + start_sha: merge_request_diff.start_commit_sha, + head_sha: merge_request_diff.head_commit_sha + ) + else + diff_refs + end + end + def validate_branches if target_project == source_project && target_branch == source_branch errors.add :branch_conflict, "You can not use same project/branch for source and target" @@ -300,6 +321,8 @@ class MergeRequest < ActiveRecord::Base merge_request_diff.reload_content + MergeRequests::MergeRequestDiffCacheService.new.execute(self) + new_diff_refs = self.diff_refs update_diff_notes_positions( @@ -659,7 +682,7 @@ class MergeRequest < ActiveRecord::Base end def support_new_diff_notes? - diff_refs && diff_refs.complete? + diff_sha_refs && diff_sha_refs.complete? end def update_diff_notes_positions(old_diff_refs:, new_diff_refs:) diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 3f520c8f3ff..32cc6a3bfea 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -33,12 +33,12 @@ class MergeRequestDiff < ActiveRecord::Base end def size - real_size.presence || diffs.size + real_size.presence || raw_diffs.size end - def diffs(options={}) + def raw_diffs(options = {}) if options[:ignore_whitespace_change] - @diffs_no_whitespace ||= begin + @raw_diffs_no_whitespace ||= begin compare = Gitlab::Git::Compare.new( repository.raw_repository, self.start_commit_sha || self.target_branch_sha, @@ -47,8 +47,8 @@ class MergeRequestDiff < ActiveRecord::Base compare.diffs(options) end else - @diffs ||= {} - @diffs[options] ||= load_diffs(st_diffs, options) + @raw_diffs ||= {} + @raw_diffs[options] ||= load_diffs(st_diffs, options) end end @@ -82,6 +82,10 @@ class MergeRequestDiff < ActiveRecord::Base project.commit(self.head_commit_sha) end + def diff_refs_by_sha? + base_commit_sha? && head_commit_sha? && start_commit_sha? + end + def compare @compare ||= begin diff --git a/app/models/note.rb b/app/models/note.rb index 9b0a7211b4e..ddcd7f9d034 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -5,6 +5,7 @@ class Note < ActiveRecord::Base include Mentionable include Awardable include Importable + include FasterCacheKeys # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. @@ -69,7 +70,7 @@ class Note < ActiveRecord::Base project: [:project_members, { group: [:group_members] }]) end - before_validation :clear_blank_line_code! + before_validation :nullify_blank_type, :nullify_blank_line_code after_save :keep_around_commit class << self @@ -217,10 +218,6 @@ class Note < ActiveRecord::Base !system? end - def clear_blank_line_code! - self.line_code = nil if self.line_code.blank? - end - def can_be_award_emoji? noteable.is_a?(Awardable) end @@ -238,4 +235,12 @@ class Note < ActiveRecord::Base def keep_around_commit project.repository.keep_around(self.commit_id) end + + def nullify_blank_type + self.type = nil if self.type.blank? + end + + def nullify_blank_line_code + self.line_code = nil if self.line_code.blank? + end end diff --git a/app/models/project.rb b/app/models/project.rb index 5452d9f768f..a667857d058 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -378,11 +378,6 @@ class Project < ActiveRecord::Base joins(join_body).reorder('join_note_counts.amount DESC') end - - # Deletes gitlab project export files older than 24 hours - def remove_gitlab_exports! - Gitlab::Popen.popen(%W(find #{Gitlab::ImportExport.storage_path} -not -path #{Gitlab::ImportExport.storage_path} -mmin +1440 -delete)) - end end def repository_storage_path @@ -451,7 +446,9 @@ class Project < ActiveRecord::Base def add_import_job if forked? - job_id = RepositoryForkWorker.perform_async(self.id, forked_from_project.path_with_namespace, self.namespace.path) + job_id = RepositoryForkWorker.perform_async(id, forked_from_project.repository_storage_path, + forked_from_project.path_with_namespace, + self.namespace.path) else job_id = RepositoryImportWorker.perform_async(self.id) end @@ -584,7 +581,11 @@ class Project < ActiveRecord::Base end def to_param - path + if persisted? && errors.include?(:path) + path_was + else + path + end end def to_reference(_from_project = nil) @@ -599,6 +600,13 @@ class Project < ActiveRecord::Base web_url.split('://')[1] end + def new_issue_address(author) + if Gitlab::IncomingEmail.enabled? && author + Gitlab::IncomingEmail.reply_address( + "#{path_with_namespace}+#{author.authentication_token}") + end + end + def build_commit_note(commit) notes.new(commit_id: commit.id, noteable_type: 'Commit') end @@ -857,16 +865,14 @@ class Project < ActiveRecord::Base # Check if current branch name is marked as protected in the system def protected_branch?(branch_name) + return true if empty_repo? && default_branch_protected? + @protected_branches ||= self.protected_branches.to_a ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present? end - def developers_can_push_to_protected_branch?(branch_name) - protected_branches.matching(branch_name).any?(&:developers_can_push) - end - - def developers_can_merge_to_protected_branch?(branch_name) - protected_branches.matching(branch_name).any?(&:developers_can_merge) + def user_can_push_to_empty_repo?(user) + !default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER end def forked? @@ -882,9 +888,13 @@ class Project < ActiveRecord::Base old_path_with_namespace = File.join(namespace_dir, path_was) new_path_with_namespace = File.join(namespace_dir, path) + Rails.logger.error "Attempting to rename #{old_path_with_namespace} -> #{new_path_with_namespace}" + expire_caches_before_rename(old_path_with_namespace) if has_container_registry_tags? + Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present" + # we currently doesn't support renaming repository if it contains tags in container registry raise Exception.new('Project cannot be renamed, because tags are present in its container registry') end @@ -903,17 +913,22 @@ class Project < ActiveRecord::Base SystemHooksService.new.execute_hooks_for(self, :rename) @repository = nil - rescue + rescue => e + Rails.logger.error "Exception renaming #{old_path_with_namespace} -> #{new_path_with_namespace}: #{e}" # Returning false does not rollback after_* transaction but gives # us information about failing some of tasks false end else + Rails.logger.error "Repository could not be renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}" + # if we cannot move namespace directory we should rollback # db changes in order to prevent out of sync between db and fs raise Exception.new('repository cannot be renamed') end + Gitlab::AppLogger.info "Project was renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}" + Gitlab::UploadsTransfer.new.rename_project(path_was, path, namespace.path) end @@ -1142,7 +1157,10 @@ class Project < ActiveRecord::Base def schedule_delete!(user_id, params) # Queue this task for after the commit, so once we mark pending_delete it will run - run_after_commit { ProjectDestroyWorker.perform_async(id, user_id, params) } + run_after_commit do + job_id = ProjectDestroyWorker.perform_async(id, user_id, params) + Rails.logger.info("User #{user_id} scheduled destruction of project #{path_with_namespace} with job ID #{job_id}") + end update_attribute(:pending_delete, true) end @@ -1236,8 +1254,23 @@ class Project < ActiveRecord::Base authorized_for_user_by_shared_projects?(user, min_access_level) end + def append_or_update_attribute(name, value) + old_values = public_send(name.to_s) + + if Project.reflect_on_association(name).try(:macro) == :has_many && old_values.any? + update_attribute(name, old_values + value) + else + update_attribute(name, value) + end + end + private + def default_branch_protected? + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE + end + def authorized_for_user_by_group?(user, min_access_level) member = user.group_members.find_by(source_id: group) diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index 23e5b16221b..d7c986c1a91 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -46,7 +46,7 @@ class HipchatService < Service return unless supported_events.include?(data[:object_kind]) message = create_message(data) return unless message.present? - gate[room].send('GitLab', message, message_options) + gate[room].send('GitLab', message, message_options(data)) end def test(data) @@ -67,8 +67,8 @@ class HipchatService < Service @gate ||= HipChat::Client.new(token, options) end - def message_options - { notify: notify.present? && notify == '1', color: color || 'yellow' } + def message_options(data = nil) + { notify: notify.present? && notify == '1', color: message_color(data) } end def create_message(data) @@ -240,6 +240,21 @@ class HipchatService < Service "#{project_link}: Commit #{commit_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)" end + def message_color(data) + build_status_color(data) || color || 'yellow' + end + + def build_status_color(data) + return unless data && data[:object_kind] == 'build' + + case data[:commit][:status] + when 'success' + 'green' + else + 'red' + end + end + def project_name project.name_with_namespace.gsub(/\s/, '') end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 0b700930641..d0a714cd6fc 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -34,7 +34,7 @@ class ProjectTeam end def add_users(users, access, current_user = nil) - ProjectMember.add_users_into_projects( + ProjectMember.add_users_to_projects( [project.id], users, access, @@ -132,39 +132,68 @@ class ProjectTeam Gitlab::Access.options_with_owner.key(max_member_access(user_id)) end - # This method assumes project and group members are eager loaded for optimal - # performance. - def max_member_access(user_id) - access = [] + # Determine the maximum access level for a group of users in bulk. + # + # Returns a Hash mapping user ID -> maximum access level. + def max_member_access_for_user_ids(user_ids) + user_ids = user_ids.uniq + key = "max_member_access:#{project.id}" - access += project.members.where(user_id: user_id).has_access.pluck(:access_level) + access = {} - if group - access += group.members.where(user_id: user_id).has_access.pluck(:access_level) + if RequestStore.active? + RequestStore.store[key] ||= {} + access = RequestStore.store[key] end - if project.invited_groups.any? && project.allowed_to_share_with_group? - access << max_invited_level(user_id) + # Lookup only the IDs we need + user_ids = user_ids - access.keys + + if user_ids.present? + user_ids.each { |id| access[id] = Gitlab::Access::NO_ACCESS } + + member_access = project.members.access_for_user_ids(user_ids) + merge_max!(access, member_access) + + if group + group_access = group.members.access_for_user_ids(user_ids) + merge_max!(access, group_access) + end + + # Each group produces a list of maximum access level per user. We take the + # max of the values produced by each group. + if project.invited_groups.any? && project.allowed_to_share_with_group? + project.project_group_links.each do |group_link| + invited_access = max_invited_level_for_users(group_link, user_ids) + merge_max!(access, invited_access) + end + end end - access.compact.max + access + end + + def max_member_access(user_id) + max_member_access_for_user_ids([user_id])[user_id] end private - def max_invited_level(user_id) - project.project_group_links.map do |group_link| - invited_group = group_link.group - access = invited_group.group_members.find_by(user_id: user_id).try(:access_field) + # For a given group, return the maximum access level for the user. This is the min of + # the invited access level of the group and the access level of the user within the group. + # For example, if the group has been given DEVELOPER access but the member has MASTER access, + # the user should receive only DEVELOPER access. + def max_invited_level_for_users(group_link, user_ids) + invited_group = group_link.group + capped_access_level = group_link.group_access + access = invited_group.group_members.access_for_user_ids(user_ids) - # If group member has higher access level we should restrict it - # to max allowed access level - if access && access > group_link.group_access - access = group_link.group_access - end + # If the user is not in the list, assume he/she does not have access + missing_users = user_ids - access.keys + missing_users.each { |id| access[id] = Gitlab::Access::NO_ACCESS } - access - end.compact.max + # Cap the maximum access by the invited level access + access.each { |key, value| access[key] = [value, capped_access_level].min } end def fetch_members(level = nil) @@ -173,7 +202,7 @@ class ProjectTeam invited_members = [] if project.invited_groups.any? && project.allowed_to_share_with_group? - project.project_group_links.each do |group_link| + project.project_group_links.includes(group: [:group_members]).each do |group_link| invited_group = group_link.group im = invited_group.members @@ -215,4 +244,8 @@ class ProjectTeam def group project.group end + + def merge_max!(first_hash, second_hash) + first_hash.merge!(second_hash) { |_key, old, new| old > new ? old : new } + end end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index b7011d7afdf..226b3f54342 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -5,6 +5,12 @@ class ProtectedBranch < ActiveRecord::Base validates :name, presence: true validates :project, presence: true + has_one :merge_access_level, dependent: :destroy + has_one :push_access_level, dependent: :destroy + + accepts_nested_attributes_for :push_access_level + accepts_nested_attributes_for :merge_access_level + def commit project.commit(self.name) end diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb new file mode 100644 index 00000000000..b1112ee737d --- /dev/null +++ b/app/models/protected_branch/merge_access_level.rb @@ -0,0 +1,24 @@ +class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base + belongs_to :protected_branch + delegate :project, to: :protected_branch + + validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, + Gitlab::Access::DEVELOPER] } + + def self.human_access_levels + { + Gitlab::Access::MASTER => "Masters", + Gitlab::Access::DEVELOPER => "Developers + Masters" + }.with_indifferent_access + end + + def check_access(user) + return true if user.is_admin? + + project.team.max_member_access(user.id) >= access_level + end + + def humanize + self.class.human_access_levels[self.access_level] + end +end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb new file mode 100644 index 00000000000..6a5e49cf453 --- /dev/null +++ b/app/models/protected_branch/push_access_level.rb @@ -0,0 +1,27 @@ +class ProtectedBranch::PushAccessLevel < ActiveRecord::Base + belongs_to :protected_branch + delegate :project, to: :protected_branch + + validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::NO_ACCESS] } + + def self.human_access_levels + { + Gitlab::Access::MASTER => "Masters", + Gitlab::Access::DEVELOPER => "Developers + Masters", + Gitlab::Access::NO_ACCESS => "No one" + }.with_indifferent_access + end + + def check_access(user) + return false if access_level == Gitlab::Access::NO_ACCESS + return true if user.is_admin? + + project.team.max_member_access(user.id) >= access_level + end + + def humanize + self.class.human_access_levels[self.access_level] + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index 46a04eb80cd..e56bac509a4 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -163,7 +163,7 @@ class Repository before_remove_branch branch = find_branch(branch_name) - oldrev = branch.try(:target) + oldrev = branch.try(:target).try(:id) newrev = Gitlab::Git::BLANK_SHA ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name @@ -211,11 +211,23 @@ class Repository return if kept_around?(sha) - rugged.references.create(keep_around_ref_name(sha), sha) + # This will still fail if the file is corrupted (e.g. 0 bytes) + begin + rugged.references.create(keep_around_ref_name(sha), sha, force: true) + rescue Rugged::ReferenceError => ex + Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}" + rescue Rugged::OSError => ex + raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/ + Rails.logger.error "Unable to create keep-around reference for repository #{path}: #{ex}" + end end def kept_around?(sha) - ref_exists?(keep_around_ref_name(sha)) + begin + ref_exists?(keep_around_ref_name(sha)) + rescue Rugged::ReferenceError + false + end end def tag_names @@ -360,7 +372,7 @@ class Repository # We don't want to flush the cache if the commit didn't actually make any # changes to any of the possible avatar files. if revision && commit = self.commit(revision) - return unless commit.diffs. + return unless commit.raw_diffs(deltas_only: true). any? { |diff| AVATAR_FILES.include?(diff.new_path) } end @@ -589,7 +601,7 @@ class Repository commit(sha) end - def next_branch(name, opts={}) + def next_branch(name, opts = {}) branch_ids = self.branch_names.map do |n| next 1 if n == name result = n.match(/\A#{name}-([0-9]+)\z/) @@ -606,11 +618,13 @@ class Repository # Remove archives older than 2 hours def branches_sorted_by(value) case value - when 'recently_updated' + when 'name' + branches.sort_by(&:name) + when 'updated_desc' branches.sort do |a, b| commit(b.target).committed_date <=> commit(a.target).committed_date end - when 'last_updated' + when 'updated_asc' branches.sort do |a, b| commit(a.target).committed_date <=> commit(b.target).committed_date end @@ -622,9 +636,7 @@ class Repository def tags_sorted_by(value) case value when 'name' - # Would be better to use `sort_by` but `version_sorter` only exposes - # `sort` and `rsort` - VersionSorter.rsort(tag_names).map { |tag_name| find_tag(tag_name) } + VersionSorter.rsort(tags) { |tag| tag.name } when 'updated_desc' tags_sorted_by_committed_date.reverse when 'updated_asc' @@ -963,7 +975,7 @@ class Repository was_empty = empty? if !was_empty && target_branch - oldrev = target_branch.target + oldrev = target_branch.target.id end # Make commit @@ -977,9 +989,13 @@ class Repository if was_empty || !target_branch # Create branch rugged.references.create(ref, newrev) + + # If repo was empty expire cache + after_create if was_empty + after_create_branch else # Update head - current_head = find_branch(branch).target + current_head = find_branch(branch).target.id # Make sure target branch was not changed during pre-receive hook if current_head == oldrev @@ -1025,7 +1041,7 @@ class Repository private def cache - @cache ||= RepositoryCache.new(path_with_namespace) + @cache ||= RepositoryCache.new(path_with_namespace, @project.id) end def head_exists? diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 3d5fd9d3ee9..c3de278f5b7 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -44,7 +44,11 @@ class WikiPage # The escaped URL path of this page. def slug - @attributes[:slug] + if @attributes[:slug].present? + @attributes[:slug] + else + wiki.wiki.preview_page(title, '', format).url_path + end end alias_method :to_param, :slug diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index e294a962352..6072123b851 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -24,10 +24,14 @@ module Auth token[:access] = names.map do |name| { type: 'repository', name: name, actions: %w(*) } end - + token.encoded end + def self.token_expire_at + Time.now + current_application_settings.container_registry_token_expire_delay.minutes + end + private def authorized_token(*accesses) @@ -35,7 +39,7 @@ module Auth token.issuer = registry.issuer token.audience = params[:service] token.subject = current_user.try(:username) - token.expire_time = ContainerRegistryAuthenticationService.token_expire_at + token.expire_time = self.class.token_expire_at token[:access] = accesses.compact token end @@ -81,9 +85,5 @@ module Auth def registry Gitlab.config.registry end - - def self.token_expire_at - Time.now + current_application_settings.container_registry_token_expire_delay.minutes - end end end diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index 149822aa647..6d6075628af 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -20,10 +20,12 @@ class CompareService ) end - Gitlab::Git::Compare.new( + raw_compare = Gitlab::Git::Compare.new( target_project.repository.raw_repository, target_branch, - source_sha, + source_sha ) + + Compare.new(raw_compare, target_project) end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index e02b50ff9a2..3f6a177bf3a 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -88,9 +88,18 @@ class GitPushService < BaseService # Set protection on the default branch if configured if current_application_settings.default_branch_protection != PROTECTION_NONE - developers_can_push = current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? true : false - developers_can_merge = current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? true : false - @project.protected_branches.create({ name: @project.default_branch, developers_can_push: developers_can_push, developers_can_merge: developers_can_merge }) + + params = { + name: @project.default_branch, + push_access_level_attributes: { + access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + }, + merge_access_level_attributes: { + access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + } + } + + ProtectedBranches::CreateService.new(@project, current_user, params).execute end end diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb new file mode 100644 index 00000000000..6442406d77e --- /dev/null +++ b/app/services/import_export_clean_up_service.rb @@ -0,0 +1,24 @@ +class ImportExportCleanUpService + LAST_MODIFIED_TIME_IN_MINUTES = 1440 + + attr_reader :mmin, :path + + def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES) + @mmin = mmin + @path = Gitlab::ImportExport.storage_path + end + + def execute + Gitlab::Metrics.measure(:import_export_clean_up) do + return unless File.directory?(path) + + clean_up_export_files + end + end + + private + + def clean_up_export_files + Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete)) + end +end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index e63e1af8766..5e2de2ccf64 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -2,10 +2,14 @@ module Issues class CreateService < Issues::BaseService def execute filter_params - label_params = params[:label_ids] - issue = project.issues.new(params.except(:label_ids)) + label_params = params.delete(:label_ids) + request = params.delete(:request) + api = params.delete(:api) + issue = project.issues.new(params) issue.author = params[:author] || current_user + issue.spam = spam_check_service.execute(request, api) + if issue.save issue.update_attributes(label_ids: label_params) notification_service.new_issue(issue, current_user) @@ -17,5 +21,11 @@ module Issues issue end + + private + + def spam_check_service + SpamCheckService.new(project, current_user, params) + end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index bc3606a14c2..ba424b09463 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -17,16 +17,19 @@ module MergeRequests end end - def hook_data(merge_request, action) + def hook_data(merge_request, action, oldrev = nil) hook_data = merge_request.to_hook_data(current_user) hook_data[:object_attributes][:url] = Gitlab::UrlBuilder.build(merge_request) hook_data[:object_attributes][:action] = action + if oldrev && !Gitlab::Git.blank_ref?(oldrev) + hook_data[:object_attributes][:oldrev] = oldrev + end hook_data end - def execute_hooks(merge_request, action = 'open') + def execute_hooks(merge_request, action = 'open', oldrev = nil) if merge_request.project - merge_data = hook_data(merge_request, action) + merge_data = hook_data(merge_request, action, oldrev) merge_request.project.execute_hooks(merge_data, :merge_request_hooks) merge_request.project.execute_services(merge_data, :merge_request_hooks) end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 7fe57747265..290742f1506 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -34,7 +34,7 @@ module MergeRequests # At this point we decide if merge request can be created # If we have at least one commit to merge -> creation allowed if commits.present? - merge_request.compare_commits = Commit.decorate(commits, merge_request.source_project) + merge_request.compare_commits = commits merge_request.can_be_created = true merge_request.compare = compare else diff --git a/app/services/merge_requests/merge_request_diff_cache_service.rb b/app/services/merge_requests/merge_request_diff_cache_service.rb new file mode 100644 index 00000000000..2945a7fd4e4 --- /dev/null +++ b/app/services/merge_requests/merge_request_diff_cache_service.rb @@ -0,0 +1,8 @@ +module MergeRequests + class MergeRequestDiffCacheService + def execute(merge_request) + # Executing the iteration we cache all the highlighted diff information + merge_request.diffs.diff_files.to_a + end + end +end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 0dac0614141..b037780c431 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -35,7 +35,13 @@ module MergeRequests } commit_id = repository.merge(current_user, merge_request, options) - merge_request.update(merge_commit_sha: commit_id) + + if commit_id + merge_request.update(merge_commit_sha: commit_id) + else + merge_request.update(merge_error: 'Conflicts detected during merge') + false + end rescue GitHooksService::PreReceiveError => e merge_request.update(merge_error: e.message) false diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 1daf6bbf553..5cedd6f11d9 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -137,7 +137,7 @@ module MergeRequests # Call merge request webhook with update branches def execute_mr_web_hooks merge_requests_for_source_branch.each do |merge_request| - execute_hooks(merge_request, 'update') + execute_hooks(merge_request, 'update', @oldrev) end end diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb new file mode 100644 index 00000000000..3cf4264ce9b --- /dev/null +++ b/app/services/projects/enable_deploy_key_service.rb @@ -0,0 +1,17 @@ +module Projects + class EnableDeployKeyService < BaseService + def execute + key = accessible_keys.find_by(id: params[:key_id] || params[:id]) + return unless key + + project.deploy_keys << key + key + end + + private + + def accessible_keys + current_user.accessible_deploy_keys + end + end +end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index f06311511cc..921ca6748d3 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -3,7 +3,7 @@ module Projects def execute # check that user is allowed to set specified visibility_level new_visibility = params[:visibility_level] - + if new_visibility && new_visibility.to_i != project.visibility_level unless can?(current_user, :change_visibility_level, project) && Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb new file mode 100644 index 00000000000..6150a2a83c9 --- /dev/null +++ b/app/services/protected_branches/create_service.rb @@ -0,0 +1,27 @@ +module ProtectedBranches + class CreateService < BaseService + attr_reader :protected_branch + + def execute + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) + + protected_branch = project.protected_branches.new(params) + + ProtectedBranch.transaction do + protected_branch.save! + + if protected_branch.push_access_level.blank? + protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) + end + + if protected_branch.merge_access_level.blank? + protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) + end + end + + protected_branch + rescue ActiveRecord::RecordInvalid + protected_branch + end + end +end diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb new file mode 100644 index 00000000000..89d8ba60134 --- /dev/null +++ b/app/services/protected_branches/update_service.rb @@ -0,0 +1,13 @@ +module ProtectedBranches + class UpdateService < BaseService + attr_reader :protected_branch + + def execute(protected_branch) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) + + @protected_branch = protected_branch + @protected_branch.update(params) + @protected_branch + end + end +end diff --git a/app/services/repository_archive_clean_up_service.rb b/app/services/repository_archive_clean_up_service.rb index 0b56b09738d..aa84d36a206 100644 --- a/app/services/repository_archive_clean_up_service.rb +++ b/app/services/repository_archive_clean_up_service.rb @@ -1,6 +1,8 @@ class RepositoryArchiveCleanUpService LAST_MODIFIED_TIME_IN_MINUTES = 120 + attr_reader :mmin, :path + def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES) @mmin = mmin @path = Gitlab.config.gitlab.repository_downloads_path @@ -17,8 +19,6 @@ class RepositoryArchiveCleanUpService private - attr_reader :mmin, :path - def clean_up_old_archives run(%W(find #{path} -not -path #{path} -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -maxdepth 2 -mmin +#{mmin} -delete)) end diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb new file mode 100644 index 00000000000..7c3e692bde9 --- /dev/null +++ b/app/services/spam_check_service.rb @@ -0,0 +1,38 @@ +class SpamCheckService < BaseService + include Gitlab::AkismetHelper + + attr_accessor :request, :api + + def execute(request, api) + @request, @api = request, api + return false unless request || check_for_spam?(project) + return false unless is_spam?(request.env, current_user, text) + + create_spam_log + + true + end + + private + + def text + [params[:title], params[:description]].reject(&:blank?).join("\n") + end + + def spam_log_attrs + { + user_id: current_user.id, + project_id: project.id, + title: params[:title], + description: params[:description], + source_ip: client_ip(request.env), + user_agent: user_agent(request.env), + noteable_type: 'Issue', + via_api: api + } + end + + def create_spam_log + CreateSpamLogService.new(project, current_user, spam_log_attrs).execute + end +end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 1ab3b5789bc..e13dc9265b8 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -2,7 +2,9 @@ # # Used for creating system notes (e.g., when a user references a merge request # from an issue, an issue's assignee changes, an issue is closed, etc.) -class SystemNoteService +module SystemNoteService + extend self + # Called when commits are added to a Merge Request # # noteable - Noteable object @@ -15,7 +17,7 @@ class SystemNoteService # See new_commit_summary and existing_commit_summary. # # Returns the created Note object - def self.add_commits(noteable, project, author, new_commits, existing_commits = [], oldrev = nil) + def add_commits(noteable, project, author, new_commits, existing_commits = [], oldrev = nil) total_count = new_commits.length + existing_commits.length commits_text = "#{total_count} commit".pluralize(total_count) @@ -40,7 +42,7 @@ class SystemNoteService # "Reassigned to @rspeicher" # # Returns the created Note object - def self.change_assignee(noteable, project, author, assignee) + def change_assignee(noteable, project, author, assignee) body = assignee.nil? ? 'Assignee removed' : "Reassigned to #{assignee.to_reference}" create_note(noteable: noteable, project: project, author: author, note: body) @@ -63,7 +65,7 @@ class SystemNoteService # "Removed ~5 label" # # Returns the created Note object - def self.change_label(noteable, project, author, added_labels, removed_labels) + def change_label(noteable, project, author, added_labels, removed_labels) labels_count = added_labels.count + removed_labels.count references = ->(label) { label.to_reference(format: :id) } @@ -101,7 +103,7 @@ class SystemNoteService # "Miletone changed to 7.11" # # Returns the created Note object - def self.change_milestone(noteable, project, author, milestone) + def change_milestone(noteable, project, author, milestone) body = 'Milestone ' body += milestone.nil? ? 'removed' : "changed to #{milestone.to_reference(project)}" @@ -123,7 +125,7 @@ class SystemNoteService # "Status changed to closed by bc17db76" # # Returns the created Note object - def self.change_status(noteable, project, author, status, source) + def change_status(noteable, project, author, status, source) body = "Status changed to #{status}" body << " by #{source.gfm_reference(project)}" if source @@ -131,26 +133,26 @@ class SystemNoteService end # Called when 'merge when build succeeds' is executed - def self.merge_when_build_succeeds(noteable, project, author, last_commit) + def merge_when_build_succeeds(noteable, project, author, last_commit) body = "Enabled an automatic merge when the build for #{last_commit.to_reference(project)} succeeds" create_note(noteable: noteable, project: project, author: author, note: body) end # Called when 'merge when build succeeds' is canceled - def self.cancel_merge_when_build_succeeds(noteable, project, author) + def cancel_merge_when_build_succeeds(noteable, project, author) body = 'Canceled the automatic merge' create_note(noteable: noteable, project: project, author: author, note: body) end - def self.remove_merge_request_wip(noteable, project, author) + def remove_merge_request_wip(noteable, project, author) body = 'Unmarked this merge request as a Work In Progress' create_note(noteable: noteable, project: project, author: author, note: body) end - def self.add_merge_request_wip(noteable, project, author) + def add_merge_request_wip(noteable, project, author) body = 'Marked this merge request as a **Work In Progress**' create_note(noteable: noteable, project: project, author: author, note: body) @@ -168,7 +170,7 @@ class SystemNoteService # "Title changed from **Old** to **New**" # # Returns the created Note object - def self.change_title(noteable, project, author, old_title) + def change_title(noteable, project, author, old_title) new_title = noteable.title.dup old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs @@ -191,7 +193,7 @@ class SystemNoteService # "Made the issue confidential" # # Returns the created Note object - def self.change_issue_confidentiality(issue, project, author) + def change_issue_confidentiality(issue, project, author) body = issue.confidential ? 'Made the issue confidential' : 'Made the issue visible' create_note(noteable: issue, project: project, author: author, note: body) end @@ -210,7 +212,7 @@ class SystemNoteService # "Target branch changed from `Old` to `New`" # # Returns the created Note object - def self.change_branch(noteable, project, author, branch_type, old_branch, new_branch) + def change_branch(noteable, project, author, branch_type, old_branch, new_branch) body = "#{branch_type} branch changed from `#{old_branch}` to `#{new_branch}`".capitalize create_note(noteable: noteable, project: project, author: author, note: body) end @@ -229,7 +231,7 @@ class SystemNoteService # "Restored target branch `feature`" # # Returns the created Note object - def self.change_branch_presence(noteable, project, author, branch_type, branch, presence) + def change_branch_presence(noteable, project, author, branch_type, branch, presence) verb = if presence == :add 'restored' @@ -245,7 +247,7 @@ class SystemNoteService # Example note text: # # "Started branch `201-issue-branch-button`" - def self.new_issue_branch(issue, project, author, branch) + def new_issue_branch(issue, project, author, branch) h = Gitlab::Routing.url_helpers link = h.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch) @@ -270,7 +272,7 @@ class SystemNoteService # See cross_reference_note_content. # # Returns the created Note object - def self.cross_reference(noteable, mentioner, author) + def cross_reference(noteable, mentioner, author) return if cross_reference_disallowed?(noteable, mentioner) gfm_reference = mentioner.gfm_reference(noteable.project) @@ -294,7 +296,7 @@ class SystemNoteService end end - def self.cross_reference?(note_text) + def cross_reference?(note_text) note_text.start_with?(cross_reference_note_prefix) end @@ -308,7 +310,7 @@ class SystemNoteService # mentioner - Mentionable object # # Returns Boolean - def self.cross_reference_disallowed?(noteable, mentioner) + def cross_reference_disallowed?(noteable, mentioner) return true if noteable.is_a?(ExternalIssue) && !noteable.project.jira_tracker_active? return false unless mentioner.is_a?(MergeRequest) return false unless noteable.is_a?(Commit) @@ -328,7 +330,7 @@ class SystemNoteService # # Returns Boolean - def self.cross_reference_exists?(noteable, mentioner) + def cross_reference_exists?(noteable, mentioner) # Initial scope should be system notes of this noteable type notes = Note.system.where(noteable_type: noteable.class) @@ -342,9 +344,60 @@ class SystemNoteService notes_for_mentioner(mentioner, noteable, notes).count > 0 end + # Build an Array of lines detailing each commit added in a merge request + # + # new_commits - Array of new Commit objects + # + # Returns an Array of Strings + def new_commit_summary(new_commits) + new_commits.collect do |commit| + "* #{commit.short_id} - #{escape_html(commit.title)}" + end + end + + # Called when the status of a Task has changed + # + # noteable - Noteable object. + # project - Project owning noteable + # author - User performing the change + # new_task - TaskList::Item object. + # + # Example Note text: + # + # "Soandso marked the task Whatever as completed." + # + # Returns the created Note object + def change_task_status(noteable, project, author, new_task) + status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE + body = "Marked the task **#{new_task.source}** as #{status_label}" + create_note(noteable: noteable, project: project, author: author, note: body) + end + + # Called when noteable has been moved to another project + # + # direction - symbol, :to or :from + # noteable - Noteable object + # noteable_ref - Referenced noteable + # author - User performing the move + # + # Example Note text: + # + # "Moved to some_namespace/project_new#11" + # + # Returns the created Note object + def noteable_moved(noteable, project, noteable_ref, author, direction:) + unless [:to, :from].include?(direction) + raise ArgumentError, "Invalid direction `#{direction}`" + end + + cross_reference = noteable_ref.to_reference(project) + body = "Moved #{direction} #{cross_reference}" + create_note(noteable: noteable, project: project, author: author, note: body) + end + private - def self.notes_for_mentioner(mentioner, noteable, notes) + def notes_for_mentioner(mentioner, noteable, notes) if mentioner.is_a?(Commit) notes.where('note LIKE ?', "#{cross_reference_note_prefix}%#{mentioner.to_reference(nil)}") else @@ -353,29 +406,18 @@ class SystemNoteService end end - def self.create_note(args = {}) + def create_note(args = {}) Note.create(args.merge(system: true)) end - def self.cross_reference_note_prefix + def cross_reference_note_prefix 'mentioned in ' end - def self.cross_reference_note_content(gfm_reference) + def cross_reference_note_content(gfm_reference) "#{cross_reference_note_prefix}#{gfm_reference}" end - # Build an Array of lines detailing each commit added in a merge request - # - # new_commits - Array of new Commit objects - # - # Returns an Array of Strings - def self.new_commit_summary(new_commits) - new_commits.collect do |commit| - "* #{commit.short_id} - #{escape_html(commit.title)}" - end - end - # Build a single line summarizing existing commits being added in a merge # request # @@ -392,7 +434,7 @@ class SystemNoteService # "* ea0f8418 - 1 commit from branch `feature`" # # Returns a newline-terminated String - def self.existing_commit_summary(noteable, existing_commits, oldrev = nil) + def existing_commit_summary(noteable, existing_commits, oldrev = nil) return '' if existing_commits.empty? count = existing_commits.size @@ -415,47 +457,7 @@ class SystemNoteService "* #{commit_ids} - #{commits_text} from branch `#{branch}`\n" end - # Called when the status of a Task has changed - # - # noteable - Noteable object. - # project - Project owning noteable - # author - User performing the change - # new_task - TaskList::Item object. - # - # Example Note text: - # - # "Soandso marked the task Whatever as completed." - # - # Returns the created Note object - def self.change_task_status(noteable, project, author, new_task) - status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE - body = "Marked the task **#{new_task.source}** as #{status_label}" - create_note(noteable: noteable, project: project, author: author, note: body) - end - - # Called when noteable has been moved to another project - # - # direction - symbol, :to or :from - # noteable - Noteable object - # noteable_ref - Referenced noteable - # author - User performing the move - # - # Example Note text: - # - # "Moved to some_namespace/project_new#11" - # - # Returns the created Note object - def self.noteable_moved(noteable, project, noteable_ref, author, direction:) - unless [:to, :from].include?(direction) - raise ArgumentError, "Invalid direction `#{direction}`" - end - - cross_reference = noteable_ref.to_reference(project) - body = "Moved #{direction} #{cross_reference}" - create_note(noteable: noteable, project: project, author: author, note: body) - end - - def self.escape_html(text) + def escape_html(text) Rack::Utils.escape_html(text) end end diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb index 1cd93263c9f..b6c52ddac7a 100644 --- a/app/uploaders/artifact_uploader.rb +++ b/app/uploaders/artifact_uploader.rb @@ -1,4 +1,3 @@ -# encoding: utf-8 class ArtifactUploader < CarrierWave::Uploader::Base storage :file diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb index a65a896e41e..fb3b5dfecd0 100644 --- a/app/uploaders/attachment_uploader.rb +++ b/app/uploaders/attachment_uploader.rb @@ -1,5 +1,3 @@ -# encoding: utf-8 - class AttachmentUploader < CarrierWave::Uploader::Base include UploaderHelper diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 03d9329a14e..71ff14a3f20 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -1,5 +1,3 @@ -# encoding: utf-8 - class AvatarUploader < CarrierWave::Uploader::Base include UploaderHelper diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 2f5f49f7de7..3ac6030c21c 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -1,4 +1,3 @@ -# encoding: utf-8 class FileUploader < CarrierWave::Uploader::Base include UploaderHelper MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)} diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index 046a1d641a9..4f356dd663e 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -1,5 +1,3 @@ -# encoding: utf-8 - class LfsObjectUploader < CarrierWave::Uploader::Base storage :file diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 23b52d08df7..c7fd344eea2 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -228,6 +228,9 @@ = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'control-label col-sm-2' .col-sm-10 = f.number_field :max_artifacts_size, class: 'form-control' + .help-block + Set the maximum file size each build's artifacts can have + = link_to "(?)", help_page_path("user/admin_area/settings/continuous_integration", anchor: "maximum-artifacts-size") - if Gitlab.config.registry.enabled %fieldset @@ -363,7 +366,9 @@ .col-sm-10 = f.select :repository_storage, repository_storage_options_for_select, {}, class: 'form-control' .help-block - You can manage the repository storage paths in your gitlab.yml configuration file + Manage repository storage paths. Learn more in the + = succeed "." do + = link_to "repository storages documentation", help_page_path("administration/repository_storages") %fieldset %legend Repository Checks @@ -385,4 +390,4 @@ .form-actions - = f.submit 'Save', class: 'btn btn-save'
\ No newline at end of file + = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/background_jobs/_head.html.haml index 9d722bd7382..89d7a40d6b0 100644 --- a/app/views/admin/background_jobs/_head.html.haml +++ b/app/views/admin/background_jobs/_head.html.haml @@ -16,3 +16,7 @@ = link_to admin_health_check_path, title: 'Health Check' do %span Health Check + = nav_link(controller: :requests_profiles) do + = link_to admin_requests_profiles_path, title: 'Requests Profiles' do + %span + Requests Profiles diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml index ce818c30c30..352adbedee4 100644 --- a/app/views/admin/builds/_build.html.haml +++ b/app/views/admin/builds/_build.html.haml @@ -11,16 +11,18 @@ - else %span.build-link ##{build.id} - - if build.stuck? - %i.fa.fa-warning.text-warning - - if build.ref + .icon-container + = build.tag? ? icon('tag') : icon('code-fork') = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name" - else .light none - = custom_icon("icon_commit") + .icon-container + = custom_icon("icon_commit") = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace commit-id" + - if build.stuck? + %i.fa.fa-warning.text-warning .label-container - if build.tags.any? diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index a2ac407c159..452fc25ab07 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -80,6 +80,10 @@ %span.pull-right = Gitlab::Shell.new.version %p + GitLab Workhorse + %span.pull-right + = Gitlab::Workhorse.version + %p GitLab API %span.pull-right = API::API::version diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml new file mode 100644 index 00000000000..ae918086a57 --- /dev/null +++ b/app/views/admin/requests_profiles/index.html.haml @@ -0,0 +1,26 @@ +- @no_container = true +- page_title 'Requests Profiles' += render 'admin/background_jobs/head' + +%div{ class: container_class } + %h3.page-title + = page_title + + .bs-callout.clearfix + Pass the header + %code X-Profile-Token: #{@profile_token} + to profile the request + + - if @profiles.present? + .prepend-top-default + - @profiles.each do |path, profiles| + .panel.panel-default.panel-small + .panel-heading + %code= path + %ul.content-list + - profiles.each do |profile| + %li + = link_to profile.time.to_s(:long), admin_requests_profile_path(profile), data: {no_turbolink: true} + - else + %p + No profiles found diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index d37489bebea..76c9ed0ee8b 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -140,12 +140,10 @@ .panel-heading This user is blocked .panel-body - %p Blocking user has the following effects: + %p A blocked user cannot: %ul - %li User will not be able to login - %li User will not be able to access git repositories - %li Personal projects will be left - %li Owned groups will be left + %li Log in + %li Access Git repositories %br = link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' } - else diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml index 8e81671b7e7..b7d3acac2b1 100644 --- a/app/views/devise/sessions/_new_crowd.html.haml +++ b/app/views/devise/sessions/_new_crowd.html.haml @@ -1,4 +1,4 @@ -= form_tag(user_omniauth_authorize_path("crowd"), id: 'new_crowd_user' ) do += form_tag(omniauth_authorize_path(:user, :crowd), id: 'new_crowd_user' ) do = text_field_tag :username, nil, {class: "form-control top", placeholder: "Username", autofocus: "autofocus"} = password_field_tag :password, nil, {class: "form-control bottom", placeholder: "Password"} - if devise_mapping.rememberable? diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index de18bc2d844..2e7da2747d0 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -5,4 +5,4 @@ - providers.each do |provider| %span.light - has_icon = provider_has_icon?(provider) - = link_to provider_image_tag(provider), user_omniauth_authorize_path(provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn'), "data-no-turbolink" => "true" + = link_to provider_image_tag(provider), omniauth_authorize_path(:user, provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn'), "data-no-turbolink" => "true" diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index eddeae98bc4..53ed4fa991d 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -6,7 +6,7 @@ .cover-block.groups-cover-block %div{ class: container_class } - = image_tag group_icon(@group), class: "avatar group-avatar s70" + = image_tag group_icon(@group), class: "avatar group-avatar s70 avatar-tile" .group-info .cover-title %h1 diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 431d312b4ca..85e188d6f8b 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -189,7 +189,7 @@ %li %a Sort by date - = link_to 'New issue', '#', class: 'btn btn-new' + = link_to 'New issue', '#', class: 'btn btn-new btn-inverted' .lead Only nav links without button and search diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index 6e993e58f0d..15dd98077c8 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -74,6 +74,4 @@ = link_to "import flow", status_import_bitbucket_path, "data-no-turbolink" => "true" again. - -:javascript - new ImporterStatus("#{jobs_import_bitbucket_path}", "#{import_bitbucket_path}"); +.js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_path}", import_path: "#{import_bitbucket_path}" } } diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml index d3d3c595c17..c8a6fa1aa9e 100644 --- a/app/views/import/fogbugz/status.html.haml +++ b/app/views/import/fogbugz/status.html.haml @@ -56,5 +56,4 @@ Import = icon("spinner spin", class: "loading-icon") -:javascript - new ImporterStatus("#{jobs_import_fogbugz_path}", "#{import_fogbugz_path}"); +.js-importer-status{ data: { jobs_import_path: "#{jobs_import_fogbugz_path}", import_path: "#{import_fogbugz_path}" } } diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index 7486b1423e2..deaaf9af875 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -55,5 +55,4 @@ Import = icon("spinner spin", class: "loading-icon") -:javascript - new ImporterStatus("#{jobs_import_github_path}", "#{import_github_path}"); +.js-importer-status{ data: { jobs_import_path: "#{jobs_import_github_path}", import_path: "#{import_github_path}" } } diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index aedb8468eca..fcfc6fd37f4 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -51,5 +51,4 @@ Import = icon("spinner spin", class: "loading-icon") -:javascript - new ImporterStatus("#{jobs_import_gitlab_path}", "#{import_gitlab_path}"); +.js-importer-status{ data: { jobs_import_path: "#{jobs_import_gitlab_path}", import_path: "#{import_gitlab_path}" } } diff --git a/app/views/import/gitorious/status.html.haml b/app/views/import/gitorious/status.html.haml index 267eee4f262..ed3afb0ce33 100644 --- a/app/views/import/gitorious/status.html.haml +++ b/app/views/import/gitorious/status.html.haml @@ -51,5 +51,4 @@ Import = icon("spinner spin", class: "loading-icon") -:javascript - new ImporterStatus("#{jobs_import_gitorious_path}", "#{import_gitorious_path}"); +.js-importer-status{ data: { jobs_import_path: "#{jobs_import_gitorious_path}", import_path: "#{import_gitorious_path}" } } diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml index 5ada6b174eb..e79f122940a 100644 --- a/app/views/import/google_code/status.html.haml +++ b/app/views/import/google_code/status.html.haml @@ -77,5 +77,4 @@ = link_to "import flow", new_import_google_code_path again. -:javascript - new ImporterStatus("#{jobs_import_google_code_path}", "#{import_google_code_path}"); +.js-importer-status{ data: { jobs_import_path: "#{jobs_import_google_code_path}", import_path: "#{import_google_code_path}" } } diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index 5ee8772882e..ac04f57e217 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -9,7 +9,7 @@ = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview - = nav_link(controller: %w(system_info background_jobs logs health_check)) do + = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do = link_to admin_system_info_path, title: 'Monitoring' do %span Monitoring diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 9e65d94186b..1d3b8fc3683 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -66,7 +66,7 @@ - if project_nav_tab? :issues = nav_link(controller: [:issues, :labels, :milestones]) do - = link_to url_for_project_issues(@project, only_path: true), title: 'Issues', class: 'shortcuts-issues' do + = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do %span Issues - if @project.default_issues_tracker? diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index d03d5e2ca6a..ee9c0366f2b 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -6,7 +6,7 @@ - content_for :scripts_body_top do - project = @target_project || @project - if @project_wiki && @page - - markdown_preview_path = namespace_project_wiki_markdown_preview_path(project.namespace, project, @page.title) + - markdown_preview_path = namespace_project_wiki_markdown_preview_path(project.namespace, project, @page.slug) - else - markdown_preview_path = markdown_preview_namespace_project_path(project.namespace, project) - if current_user diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index fc64c98038b..ca5c2f2688c 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -3,3 +3,5 @@ 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 %> + +<%= @issue.description %> diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index d4aad8d1862..3c8f178ac77 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -6,3 +6,5 @@ New Merge Request <%= @merge_request.to_reference %> Author: <%= @merge_request.author_name %> Assignee: <%= @merge_request.assignee_name %> +<%= @merge_request.description %> + diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 57d16d29158..c80f22457b4 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -70,7 +70,7 @@ = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do Disconnect - else - = link_to user_omniauth_authorize_path(provider), method: :post, class: 'provider-btn not-active', "data-no-turbolink" => "true" do + = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active', "data-no-turbolink" => "true" do Connect %hr - if current_user.can_change_username? diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index cf11723dc8e..51f74f3b7ce 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,7 +1,7 @@ - empty_repo = @project.empty_repo? .project-home-panel.text-center{ class: ("empty-project" if empty_repo) } %div{ class: container_class } - = project_icon(@project, alt: @project.name, class: 'project-avatar avatar s70') + = project_icon(@project, alt: @project.name, class: 'project-avatar avatar s70 avatar-tile') %h1.project-title = @project.name %span.visibility-icon.has-tooltip{data: { container: 'body' }, title: visibility_icon_description(@project)} diff --git a/app/views/projects/blob/_actions.html.haml b/app/views/projects/blob/_actions.html.haml index cdac50f7a8d..ff893ea74e1 100644 --- a/app/views/projects/blob/_actions.html.haml +++ b/app/views/projects/blob/_actions.html.haml @@ -16,6 +16,7 @@ - if current_user .btn-group{ role: "group" } - = edit_blob_link + - if blob_text_viewable?(@blob) + = edit_blob_link = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/diff.html.haml b/app/views/projects/blob/diff.html.haml index 5926d181ba3..a79ae53c780 100644 --- a/app/views/projects/blob/diff.html.haml +++ b/app/views/projects/blob/diff.html.haml @@ -1,20 +1,30 @@ - if @lines.present? + - line_class = diff_view == :inline ? '' : diff_view - if @form.unfold? && @form.since != 1 && !@form.bottom? - %tr.line_holder - = render "projects/diffs/match_line", { line: @match_line, - line_old: @form.since, line_new: @form.since, bottom: false, new_file: false } + %tr.line_holder{ class: line_class } + = diff_match_line @form.since, @form.since, text: @match_line, view: diff_view - @lines.each_with_index do |line, index| - line_new = index + @form.since - line_old = line_new - @form.offset - %tr.line_holder{ id: line_old } - %td.old_line.diff-line-num{ data: { linenumber: line_old } } - = link_to raw(line_old), "##{line_old}" - %td.new_line.diff-line-num{ data: { linenumber: line_old } } - = link_to raw(line_new) , "##{line_old}" - %td.line_content.noteable_line==#{' ' * @form.indent}#{line} + - line_content = capture do + %td.line_content.noteable_line{ class: line_class }==#{' ' * @form.indent}#{line} + %tr.line_holder{ id: line_old, class: line_class } + - case diff_view + - when :inline + %td.old_line.diff-line-num{ data: { linenumber: line_old } } + %a{href: "##{line_old}", data: { linenumber: line_old }} + %td.new_line.diff-line-num{ data: { linenumber: line_new } } + %a{href: "##{line_new}", data: { linenumber: line_new }} + = line_content + - when :parallel + %td.old_line.diff-line-num{data: { linenumber: line_old }} + = link_to raw(line_old), "##{line_old}" + = line_content + %td.new_line.diff-line-num{data: { linenumber: line_new }} + = link_to raw(line_new), "##{line_new}" + = line_content - if @form.unfold? && @form.bottom? && @form.to < @blob.loc - %tr.line_holder{ id: @form.to } - = render "projects/diffs/match_line", { line: @match_line, - line_old: @form.to, line_new: @form.to, bottom: true, new_file: false } + %tr.line_holder{ id: @form.to, class: line_class } + = diff_match_line @form.to, @form.to, text: @match_line, view: diff_view, bottom: true diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 77b405f1f39..e889f29c816 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -7,28 +7,32 @@ .nav-text Protected branches can be managed in project settings - - if can? current_user, :push_code, @project - .nav-controls + .nav-controls + = form_tag(filter_branches_path, method: :get) do + = search_field_tag :search, params[:search], { placeholder: 'Filter by branch name', id: 'branch-search', class: 'form-control search-text-input input-short', spellcheck: false } + + .dropdown.inline + %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} + %span.light + = projects_sort_options_hash[@sort] + %b.caret + %ul.dropdown-menu.dropdown-menu-align-right + %li + = link_to filter_branches_path(sort: sort_value_name) do + = sort_title_name + = link_to filter_branches_path(sort: sort_value_recently_updated) do + = sort_title_recently_updated + = link_to filter_branches_path(sort: sort_value_oldest_updated) do + = sort_title_oldest_updated + + - if can? current_user, :push_code, @project = link_to new_namespace_project_branch_path(@project.namespace, @project), class: 'btn btn-create' do New branch - .dropdown.inline - %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} - %span.light - - if @sort.present? - = @sort.humanize - - else - Name - %b.caret - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to namespace_project_branches_path(sort: nil) do - Name - = link_to namespace_project_branches_path(sort: 'recently_updated') do - = sort_title_recently_updated - = link_to namespace_project_branches_path(sort: 'last_updated') do - = sort_title_oldest_updated + - if @branches.any? %ul.content-list.all-branches - @branches.each do |branch| = render "projects/branches/branch", branch: branch = paginate @branches, theme: 'gitlab' + - else + .nothing-here-block No branches to show diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index dc57b49f27a..a8bc53c2849 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -40,7 +40,7 @@ .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } .title Build details - - if @build.retryable? + - if can?(current_user, :update_build, @build) && @build.retryable? = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right', method: :post - if @build.merge_request %p.build-detail-row @@ -88,8 +88,9 @@ %p %span.build-light-text Variables: - %code - - @build.trigger_request.variables.each do |key, value| + + - @build.trigger_request.variables.each do |key, value| + %code #{key}=#{value} .block diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 16b8e1cca91..ca907077c2b 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -9,7 +9,7 @@ - if can_create_issue %li - = link_to url_for_new_issue(@project, only_path: true) do + = link_to new_namespace_project_issue_path(@project.namespace, @project) do = icon('exclamation-circle fw') New issue diff --git a/app/views/projects/buttons/_fork.html.haml b/app/views/projects/buttons/_fork.html.haml index a098a082854..d78888e9fe4 100644 --- a/app/views/projects/buttons/_fork.html.haml +++ b/app/views/projects/buttons/_fork.html.haml @@ -4,15 +4,11 @@ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn has-tooltip' do = custom_icon('icon_fork') Fork - %div.count-with-arrow - %span.arrow - %span.count - = @project.forks_count - else = link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork project", class: 'btn has-tooltip' do = custom_icon('icon_fork') Fork - %div.count-with-arrow - %span.arrow - = link_to namespace_project_forks_path(@project.namespace, @project), class: "count" do - = @project.forks_count + %div.count-with-arrow + %span.arrow + = link_to namespace_project_forks_path(@project.namespace, @project), class: "count" do + = @project.forks_count diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index a9fb3c58431..91081435220 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -13,15 +13,10 @@ - else %span ##{build.id} - - if build.stuck? - .icon-container - = icon('warning', class: 'text-warning has-tooltip', title: 'Build is stuck. Check runners.') - - if defined?(retried) && retried - .icon-container - = icon('warning', class: 'text-warning has-tooltip', title: 'Build was retried.') - - if defined?(ref) && ref - if build.ref + .icon-container + = build.tag? ? icon('tag') : icon('code-fork') = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name" - else .light none @@ -31,6 +26,11 @@ - if defined?(commit_sha) && commit_sha = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "commit-id monospace" + - if build.stuck? + = icon('warning', class: 'text-warning has-tooltip', title: 'Build is stuck. Check runners.') + - if defined?(retried) && retried + = icon('warning', class: 'text-warning has-tooltip', title: 'Build was retried.') + .label-container - if build.tags.any? - build.tags.each do |tag| @@ -45,7 +45,6 @@ - if build.manual? %span.label.label-info manual - - if defined?(runner) && runner %td - if build.try(:runner) diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index 2f7d54f0bdd..9a594877803 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -53,11 +53,11 @@ - if pipeline.finished_at %p.finished-at = icon("calendar") - #{time_ago_with_tooltip(pipeline.finished_at)} + #{time_ago_with_tooltip(pipeline.finished_at, short_format: true, skip_js: true)} %td.pipeline-actions .controls.hidden-xs.pull-right - - artifacts = pipeline.builds.latest.select { |b| b.artifacts? } + - artifacts = pipeline.builds.latest.with_artifacts_not_expired - actions = pipeline.manual_actions - if artifacts.present? || actions.any? .btn-group.inline diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml index ea33aa472a6..935433306ea 100644 --- a/app/views/projects/commit/_ci_menu.html.haml +++ b/app/views/projects/commit/_ci_menu.html.haml @@ -2,7 +2,7 @@ = nav_link(path: 'commit#show') do = link_to namespace_project_commit_path(@project.namespace, @project, @commit.id) do Changes - %span.badge= @diffs.count + %span.badge= @diffs.size = nav_link(path: 'commit#builds') do = link_to builds_namespace_project_commit_path(@project.namespace, @project, @commit.id) do Builds diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index d0da2606587..ed44d86a687 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -7,7 +7,7 @@ = render "ci_menu" - else %div.block-connector -= render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @commit.diff_refs += render "projects/diffs/diffs", diffs: @diffs = render "projects/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index af09b3418ea..d79336f5a60 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -1,7 +1,7 @@ = form_tag namespace_project_compare_index_path(@project.namespace, @project), method: :post, class: 'form-inline js-requires-input' do .clearfix - if params[:to] && params[:from] - = link_to 'switch', {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip', title: 'Switch base of comparison'} + = link_to icon('exchange'), {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip', title: 'Switch base of comparison'} .form-group.dropdown.compare-form-group.js-compare-from-dropdown .input-group.inline-input-group %span.input-group-addon from diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 28a50e7031a..819e9bc15ae 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -8,7 +8,7 @@ - if @commits.present? = render "projects/commits/commit_list" - = render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @diff_refs + = render "projects/diffs/diffs", diffs: @diffs - else .light-well .center diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index a1b071f130c..d37961c4e40 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -13,7 +13,7 @@ .nothing-here-block.diff-collapsed{data: { diff_for_path: url } } This diff is collapsed. Click to expand it. - elsif diff_file.diff_lines.length > 0 - - if diff_view == 'parallel' + - if diff_view == :parallel = render "projects/diffs/parallel_view", diff_file: diff_file, project: project, blob: blob - else = render "projects/diffs/text_file", diff_file: diff_file diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 8ae433b4823..62aff36aadd 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -1,20 +1,19 @@ - show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true) -- if diff_view == 'parallel' +- diff_files = diffs.diff_files +- if diff_view == :parallel - fluid_layout true -- diff_files = safe_diff_files(diffs, diff_refs: diff_refs, repository: project.repository) - .content-block.oneline-block.files-changed .inline-parallel-buttons - if !expand_all_diffs? && diff_files.any? { |diff_file| diff_file.collapsed? } - = link_to 'Expand all', url_for(params.merge(expand_all_diffs: 1, format: 'html')), class: 'btn btn-default' + = link_to 'Expand all', url_for(params.merge(expand_all_diffs: 1, format: nil)), class: 'btn btn-default' - if show_whitespace_toggle - if current_controller?(:commit) - = commit_diff_whitespace_link(@project, @commit, class: 'hidden-xs') + = commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs') - elsif current_controller?(:merge_requests) - = diff_merge_request_whitespace_link(@project, @merge_request, class: 'hidden-xs') + = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'hidden-xs') - elsif current_controller?(:compare) - = diff_compare_whitespace_link(@project, params[:from], params[:to], class: 'hidden-xs') + = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'hidden-xs') .btn-group = inline_diff_btn = parallel_diff_btn @@ -23,12 +22,12 @@ - if diff_files.overflow? = render 'projects/diffs/warning', diff_files: diff_files -.files{data: {can_create_note: (!@diff_notes_disabled && can?(current_user, :create_note, @project))}} +.files{data: {can_create_note: (!@diff_notes_disabled && can?(current_user, :create_note, diffs.project))}} - diff_files.each_with_index do |diff_file, index| - diff_commit = commit_for_diff(diff_file) - blob = diff_file.blob(diff_commit) - next unless blob - - blob.load_all_data!(project.repository) unless blob.only_display_raw? + - blob.load_all_data!(diffs.project.repository) unless blob.only_display_raw? - = render 'projects/diffs/file', i: index, project: project, - diff_file: diff_file, diff_commit: diff_commit, blob: blob, diff_refs: diff_refs + = render 'projects/diffs/file', index: index, project: diffs.project, + diff_file: diff_file, diff_commit: diff_commit, blob: blob diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index c306909fb1a..f0a86fd6d40 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,6 +1,6 @@ -.diff-file.file-holder{id: "diff-#{i}", data: diff_file_html_data(project, diff_file)} +.diff-file.file-holder{id: "diff-#{index}", data: diff_file_html_data(project, diff_file)} .file-title{id: "file-path-#{hexdigest(diff_file.file_path)}"} - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "#diff-#{i}" + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "#diff-#{index}" - unless diff_file.submodule? .file-actions.hidden-xs @@ -9,11 +9,12 @@ = icon('comment') \ - - if editable_diff?(diff_file) - = edit_blob_link(@merge_request.source_project, - @merge_request.source_branch, diff_file.new_path, - from_merge_request_id: @merge_request.id) + - if editable_diff?(diff_file) + = edit_blob_link(@merge_request.source_project, + @merge_request.source_branch, diff_file.new_path, + from_merge_request_id: @merge_request.id, + skip_visible_check: true) - = view_file_btn(diff_commit.id, diff_file, project) + = view_file_btn(diff_commit.id, diff_file.new_path, project) - = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, diff_refs: diff_refs, blob: blob, project: project + = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 5a8a131d10c..2d6a370b848 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -1,12 +1,10 @@ - plain = local_assigns.fetch(:plain, false) -- line_code = diff_file.line_code(line) -- position = diff_file.position(line) - type = line.type -%tr.line_holder{ id: line_code, class: type } +- line_code = diff_file.line_code(line) unless plain +%tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } } - case type - when 'match' - = render "projects/diffs/match_line", { line: line.text, - line_old: line.old_pos, line_new: line.new_pos, bottom: false, new_file: diff_file.new_file } + = diff_match_line line.old_pos, line.new_pos, text: line.text - when 'nonewline' %td.old_line.diff-line-num %td.new_line.diff-line-num @@ -24,4 +22,4 @@ = link_text - else %a{href: "##{line_code}", data: { linenumber: link_text }} - %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, position, type) unless plain) }= diff_line_content(line.text, type) + %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }= diff_line_content(line.text, type) diff --git a/app/views/projects/diffs/_match_line.html.haml b/app/views/projects/diffs/_match_line.html.haml deleted file mode 100644 index d6dddd97879..00000000000 --- a/app/views/projects/diffs/_match_line.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -%td.old_line.diff-line-num{data: {linenumber: line_old}, - class: [unfold_bottom_class(bottom), unfold_class(!new_file)]} - \... -%td.new_line.diff-line-num{data: {linenumber: line_new}, - class: [unfold_bottom_class(bottom), unfold_class(!new_file)]} - \... -%td.line_content.match= line diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index 7f30faa20d8..28aad3f4725 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -1,14 +1,15 @@ / Side-by-side diff view %div.text-file.diff-wrap-lines.code.file-content.js-syntax-highlight{ data: diff_view_data } %table + - last_line = 0 - diff_file.parallel_diff_lines.each do |line| - left = line[:left] - right = line[:right] + - last_line = right.new_pos if right %tr.line_holder.parallel - if left - if left.meta? - %td.old_line.diff-line-num.empty-cell - %td.line_content.parallel.match= left.text + = diff_match_line left.old_pos, nil, text: left.text, view: :parallel - else - left_line_code = diff_file.line_code(left) - left_position = diff_file.position(left) @@ -21,8 +22,7 @@ - if right - if right.meta? - %td.old_line.diff-line-num.empty-cell - %td.line_content.parallel.match= left.text + = diff_match_line nil, right.new_pos, text: left.text, view: :parallel - else - right_line_code = diff_file.line_code(right) - right_position = diff_file.position(right) @@ -37,3 +37,5 @@ - discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file) - if discussion_left || discussion_right = render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right + - if !diff_file.new_file && last_line > 0 + = diff_match_line last_line, last_line, bottom: true, view: :parallel diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index ea2a3e01277..e751dabdf99 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -2,7 +2,7 @@ .commit-stat-summary Showing = link_to '#', class: 'js-toggle-button' do - %strong #{pluralize(diff_files.count, "changed file")} + %strong #{pluralize(diff_files.size, "changed file")} with %strong.cgreen #{diff_files.sum(&:added_lines)} additions and diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 5970b9abf2b..ab5463ba89d 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -15,6 +15,5 @@ - if discussion = render "discussions/diff_discussion", discussion: discussion - - if last_line > 0 - = render "projects/diffs/match_line", { line: "", - line_old: last_line, line_new: last_line, bottom: true, new_file: diff_file.new_file } + - if !diff_file.new_file && last_line > 0 + = diff_match_line last_line, last_line, bottom: true diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml index 10fa1ddf2e5..295a1b62535 100644 --- a/app/views/projects/diffs/_warning.html.haml +++ b/app/views/projects/diffs/_warning.html.haml @@ -11,5 +11,5 @@ = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm" %p To preserve performance only - %strong #{diff_files.count} of #{diff_files.real_size} + %strong #{diff_files.size} of #{diff_files.real_size} files are displayed. diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 921155e970b..b282aa52b25 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -4,6 +4,7 @@ %h4.prepend-top-0 Project settings .col-lg-9 + .project-edit-errors = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f| %fieldset.append-bottom-0 .form-group @@ -190,6 +191,7 @@ %h4.prepend-top-0.warning-title Rename repository .col-lg-9 + = render 'projects/errors' = form_for([@project.namespace.becomes(Namespace), @project]) do |f| .form-group.project_name_holder = f.label :name, class: 'label-light' do diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml index c07f4bd510c..6d040f5cfe6 100644 --- a/app/views/projects/environments/_form.html.haml +++ b/app/views/projects/environments/_form.html.haml @@ -1,7 +1,22 @@ -= form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { class: 'col-lg-9' } do |f| - = form_errors(@environment) - .form-group - = f.label :name, 'Name', class: 'label-light' - = f.text_field :name, required: true, class: 'form-control' - = f.submit 'Create environment', class: 'btn btn-create' - = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel' +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Environments + %p + Environments allow you to track deployments of your application + = succeed "." do + = link_to "Read more about environments", help_page_path("ci/environments") + + = form_for [@project.namespace.becomes(Namespace), @project, @environment], html: { class: 'col-lg-9' } do |f| + = form_errors(@environment) + + .form-group + = f.label :name, 'Name', class: 'label-light' + = f.text_field :name, required: true, class: 'form-control' + .form-group + = f.label :external_url, 'External URL', class: 'label-light' + = f.url_field :external_url, class: 'form-control' + + .form-actions + = f.submit 'Save', class: 'btn btn-save' + = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/environments/edit.html.haml b/app/views/projects/environments/edit.html.haml new file mode 100644 index 00000000000..6d1bdb9320f --- /dev/null +++ b/app/views/projects/environments/edit.html.haml @@ -0,0 +1,6 @@ +- page_title "Edit", @environment.name, "Environments" + +%h3.page-title + Edit environment +%hr += render 'form' diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml index 89e06567196..e51667ade2d 100644 --- a/app/views/projects/environments/new.html.haml +++ b/app/views/projects/environments/new.html.haml @@ -1,12 +1,6 @@ - page_title 'New Environment' -.row.prepend-top-default.append-bottom-default - .col-lg-3 - %h4.prepend-top-0 - New Environment - %p - Environments allow you to track deployments of your application - = succeed "." do - = link_to "Read more about environments", help_page_path("ci/environments") - - = render 'form' +%h3.page-title + New environment +%hr += render 'form' diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index b8b1ce52a91..a07436ad7c9 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -6,10 +6,10 @@ .top-area .col-md-9 %h3.page-title= @environment.name.capitalize - .col-md-3 .nav-controls - if can?(current_user, :update_environment, @environment) + = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to delete this environment?' }, class: 'btn btn-danger', method: :delete - if @deployments.blank? diff --git a/app/views/projects/graphs/ci/_build_times.haml b/app/views/projects/graphs/ci/_build_times.haml index c58223fd39e..195f18afc76 100644 --- a/app/views/projects/graphs/ci/_build_times.haml +++ b/app/views/projects/graphs/ci/_build_times.haml @@ -19,4 +19,9 @@ ] } var ctx = $("#build_timesChart").get(0).getContext("2d"); - new Chart(ctx).Bar(data,{"scaleOverlay": true, responsive: true, maintainAspectRatio: false}); + var options = { scaleOverlay: true, responsive: true, maintainAspectRatio: false }; + if (window.innerWidth < 768) { + // Scale fonts if window width lower than 768px (iPad portrait) + options.scaleFontSize = 8 + } + new Chart(ctx).Bar(data, options); diff --git a/app/views/projects/graphs/ci/_builds.haml b/app/views/projects/graphs/ci/_builds.haml index 8fca07114fa..1fbf6ca2c1c 100644 --- a/app/views/projects/graphs/ci/_builds.haml +++ b/app/views/projects/graphs/ci/_builds.haml @@ -48,4 +48,9 @@ ] } var ctx = $("##{scope}Chart").get(0).getContext("2d"); - new Chart(ctx).Line(data,{"scaleOverlay": true, responsive: true, maintainAspectRatio: false}); + var options = { scaleOverlay: true, responsive: true, maintainAspectRatio: false }; + if (window.innerWidth < 768) { + // Scale fonts if window width lower than 768px (iPad portrait) + options.scaleFontSize = 8 + } + new Chart(ctx).Line(data, options); diff --git a/app/views/projects/graphs/commits.html.haml b/app/views/projects/graphs/commits.html.haml index 65db8af494d..7e34a89f9ae 100644 --- a/app/views/projects/graphs/commits.html.haml +++ b/app/views/projects/graphs/commits.html.haml @@ -59,6 +59,10 @@ var container = $(selector).parent(); var generateChart = function() { selector.attr('width', $(container).width()); + if (window.innerWidth < 768) { + // Scale fonts if window width lower than 768px (iPad portrait) + options.scaleFontSize = 8 + } return new Chart(ctx).Bar(data, options); }; // enabling auto-resizing diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index a985b442b2d..ac5f792d140 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -32,7 +32,7 @@ :javascript $.ajax({ type: "GET", - url: location.href, + url: "#{namespace_project_graph_path(@project.namespace, @project, current_ref, format: :json)}", dataType: "json", success: function (data) { var graph = new ContributorsStatGraph(); diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml index 403adb7426b..60b45115b73 100644 --- a/app/views/projects/issues/_head.html.haml +++ b/app/views/projects/issues/_head.html.haml @@ -2,7 +2,7 @@ %ul{ class: (container_class) } - if project_nav_tab?(:issues) && !current_controller?(:merge_requests) = nav_link(controller: :issues) do - = link_to url_for_project_issues(@project, only_path: true), title: 'Issues' do + = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues' do %span Issues diff --git a/app/views/projects/issues/_issue_by_email.html.haml b/app/views/projects/issues/_issue_by_email.html.haml new file mode 100644 index 00000000000..72669372497 --- /dev/null +++ b/app/views/projects/issues/_issue_by_email.html.haml @@ -0,0 +1,27 @@ +.issues-footer.text-center + %button.issue-email-modal-btn{ type: "button", data: { toggle: "modal", target: "#issue-email-modal" } } + Email a new issue to this project + +#issue-email-modal.modal.fade{ tabindex: "-1", role: "dialog" } + .modal-dialog{ role: "document" } + .modal-content + .modal-header + %button.close{ type: "button", data: { dismiss: "modal" }, aria: { label: "close" } } + %span{ aria: { hidden: "true" } }= icon("times") + %h4.modal-title + Create new issue by email + .modal-body + %p + Write an email to the below email address. (This is a private email address, so keep it secret.) + .email-modal-input-group.input-group + = text_field_tag :issue_email, email, class: "monospace js-select-on-focus form-control", readonly: true + .input-group-btn + = clipboard_button(clipboard_target: '#issue_email') + %p + Send an email to this address to create an issue. + %p + Use the subject line as the title of your issue. + %p + Use the message as the body of your issue (feel free to include some nice + = succeed ")." do + = link_to "Markdown", help_page_path('markdown', 'markdown') diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index e93b7e0d66d..24749699c6d 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -2,7 +2,7 @@ .pull-right #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 has-tooltip', title: @issue.to_branch_name, disabled: 'disabled' do + method: :post, class: 'btn btn-new btn-inverted has-tooltip', title: @issue.to_branch_name, disabled: 'disabled' do .checking = icon('spinner spin') Checking branches diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index c6fc499a7b8..6ea9f612d13 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -4,8 +4,8 @@ %ul.unstyled-list - @related_branches.each do |branch| %li - - sha = @project.repository.find_branch(branch).target - - pipeline = @project.pipeline(sha, branch) if sha + - target = @project.repository.find_branch(branch).target + - pipeline = @project.pipeline(target.sha, branch) if target - if pipeline %span.related-branch-ci-status = render_pipeline_status(pipeline) diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 7612fe3719a..1a87045aa60 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -1,5 +1,6 @@ - @no_container = true - page_title "Issues" +- new_issue_email = @project.new_issue_address(current_user) = render "projects/issues/head" = content_for :meta_tags do @@ -18,12 +19,20 @@ Subscribe = render 'shared/issuable/search_form', path: namespace_project_issues_path(@project.namespace, @project) - if can? current_user, :create_issue, @project - = link_to new_namespace_project_issue_path(@project.namespace, @project, issue: { assignee_id: @issuable_finder.assignee.try(:id), milestone_id: @issuable_finder.milestones.try(:first).try(:id) }), class: "btn btn-new", title: "New Issue", id: "new_issue_link" do + = link_to new_namespace_project_issue_path(@project.namespace, + @project, + issue: { assignee_id: issues_finder.assignee.try(:id), + milestone_id: issues_finder.milestones.first.try(:id) }), + class: "btn btn-new", + title: "New Issue", + id: "new_issue_link" do New Issue = render 'shared/issuable/filter', type: :issues .issues-holder - = render "issues" + = render 'issues' + - if new_issue_email + = render 'issue_by_email', email: new_issue_email - else .blank-state.blank-state-welcome %h2.blank-state-title.blank-state-welcome-title @@ -40,3 +49,5 @@ - if can? current_user, :create_issue, @project = link_to new_namespace_project_issue_path(@project.namespace, @project), class: "btn btn-new", title: "New Issue", id: "new_issue_link" do New Issue + - if new_issue_email + = render 'issue_by_email', email: new_issue_email diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 9b6a97c0959..e5cce16a171 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -38,7 +38,7 @@ %li = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) - if can?(current_user, :create_issue, @project) - = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-success', title: 'New issue', id: 'new_issue_link' do + = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue - if can?(current_user, :update_issue, @issue) = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index a5e67b95727..598bd743676 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -42,7 +42,7 @@ %h4 This comparison includes more than #{MergeRequestDiff::COMMITS_SAFE_SIZE} commits. %p To preserve performance the line changes are not shown. - else - = render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @merge_request.diff_refs, show_whitespace_toggle: false + = render "projects/diffs/diffs", diffs: @diffs, show_whitespace_toggle: false - if @pipeline #builds.builds.tab-pane = render "projects/merge_requests/show/builds" diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 873ed9b59ee..269198adf91 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -2,7 +2,7 @@ - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes -- if diff_view == 'parallel' +- if diff_view == :parallel - fluid_layout true .merge-request{'data-url' => merge_request_path(@merge_request)} diff --git a/app/views/projects/merge_requests/show/_diffs.html.haml b/app/views/projects/merge_requests/show/_diffs.html.haml index 1b0bae86ad4..013b05628fa 100644 --- a/app/views/projects/merge_requests/show/_diffs.html.haml +++ b/app/views/projects/merge_requests/show/_diffs.html.haml @@ -1,6 +1,5 @@ - if @merge_request_diff.collected? - = render "projects/diffs/diffs", diffs: @merge_request.diffs(diff_options), - project: @merge_request.project, diff_refs: @merge_request.diff_refs + = render "projects/diffs/diffs", diffs: @diffs - elsif @merge_request_diff.empty? .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch} - else diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml index 720d67dff7c..04b19a8c5a7 100644 --- a/app/views/projects/protected_branches/_branches_list.html.haml +++ b/app/views/projects/protected_branches/_branches_list.html.haml @@ -1,28 +1,28 @@ -%h5.prepend-top-0 - Already Protected (#{@protected_branches.size}) -- if @protected_branches.empty? - %p.settings-message.text-center - No branches are protected, protect a branch with the form above. -- else - - can_admin_project = can?(current_user, :admin_project, @project) - .table-responsive - %table.table.protected-branches-list +.panel.panel-default.protected-branches-list + - if @protected_branches.empty? + .panel-heading + %h3.panel-title + Protected branch (#{@protected_branches.size}) + %p.settings-message.text-center + There are currently no protected branches, protect a branch with the form above. + - else + - can_admin_project = can?(current_user, :admin_project, @project) + + %table.table.table-bordered %colgroup - %col{ width: "20%" } - %col{ width: "30%" } %col{ width: "25%" } + %col{ width: "30%" } %col{ width: "25%" } - - if can_admin_project - %col + %col{ width: "20%" } %thead %tr - %th Protected Branch - %th Commit - %th Developers Can Push - %th Developers Can Merge + %th Protected branch (#{@protected_branches.size}) + %th Last commit + %th Allowed to merge + %th Allowed to push - if can_admin_project %th %tbody = render partial: @protected_branches, locals: { can_admin_project: can_admin_project } - = paginate @protected_branches, theme: 'gitlab' + = paginate @protected_branches, theme: 'gitlab' diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml new file mode 100644 index 00000000000..85d0c494ba8 --- /dev/null +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -0,0 +1,36 @@ += form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f| + .panel.panel-default + .panel-heading + %h3.panel-title + Protect a branch + .panel-body + .form-horizontal + .form-group + = f.label :name, class: 'col-md-2 text-right' do + Branch: + .col-md-10 + = render partial: "dropdown", locals: { f: f } + .help-block + = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches') + such as + %code *-stable + or + %code production/* + are supported + .form-group + %label.col-md-2.text-right{ for: 'merge_access_level_attributes' } + Allowed to merge: + .col-md-10 + = dropdown_tag('Select', + options: { toggle_class: 'js-allowed-to-merge wide', + data: { field_name: 'protected_branch[merge_access_level_attributes][access_level]', input_id: 'merge_access_level_attributes' }}) + .form-group + %label.col-md-2.text-right{ for: 'push_access_level_attributes' } + Allowed to push: + .col-md-10 + = dropdown_tag('Select', + options: { toggle_class: 'js-allowed-to-push wide', + data: { field_name: 'protected_branch[push_access_level_attributes][access_level]', input_id: 'push_access_level_attributes' }}) + + .panel-footer + = f.submit 'Protect', class: 'btn-create btn', disabled: true diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml index b803d932e67..a9e27df5a87 100644 --- a/app/views/projects/protected_branches/_dropdown.html.haml +++ b/app/views/projects/protected_branches/_dropdown.html.haml @@ -1,17 +1,15 @@ = f.hidden_field(:name) -= dropdown_tag("Protected Branch", - options: { title: "Pick protected branch", toggle_class: 'js-protected-branch-select js-filter-submit', += dropdown_tag('Select branch or create wildcard', + options: { toggle_class: 'js-protected-branch-select js-filter-submit wide', filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], project_id: @project.try(:id) } }) do - %ul.dropdown-footer-list.hidden.protected-branch-select-footer-list + %ul.dropdown-footer-list %li = link_to '#', title: "New Protected Branch", class: "create-new-protected-branch" do - Create new - -:javascript - new ProtectedBranchSelect(); + Create wildcard + %code diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index 7fda7f96047..e2e01ee78f8 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -1,5 +1,4 @@ -- url = namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) -%tr +%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch), branch_id: protected_branch.id } } %td = protected_branch.name - if @project.root_ref?(protected_branch.name) @@ -15,9 +14,15 @@ - else (branch was removed from repository) %td - = check_box_tag("developers_can_push", protected_branch.id, protected_branch.developers_can_push, data: { url: url }) + = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_level.access_level + = dropdown_tag( (protected_branch.merge_access_level.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + data: { field_name: "allowed_to_merge_#{protected_branch.id}" }}) %td - = check_box_tag("developers_can_merge", protected_branch.id, protected_branch.developers_can_merge, data: { url: url }) + = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_level.access_level + = dropdown_tag( (protected_branch.push_access_level.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + data: { field_name: "allowed_to_push_#{protected_branch.id}" }}) - if can_admin_project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning btn-sm pull-right" + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_branches/index.html.haml b/app/views/projects/protected_branches/index.html.haml index 950df740bbc..49dcc9a6ba4 100644 --- a/app/views/projects/protected_branches/index.html.haml +++ b/app/views/projects/protected_branches/index.html.haml @@ -14,37 +14,7 @@ %li prevent <strong>anyone</strong> from deleting the branch %p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}. .col-lg-9 - %h5.prepend-top-0 - Protect a branch - if can? current_user, :admin_project, @project - = form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f| - = form_errors(@protected_branch) + = render 'create_protected_branch' - .form-group - = f.label :name, "Branch", class: "label-light" - = render partial: "dropdown", locals: { f: f } - %p.help-block - = link_to "Wildcards", help_page_path('user/project/protected_branches', anchor: "wildcard-protected-branches") - such as - %code *-stable - or - %code production/* - are supported. - - .form-group - = f.check_box :developers_can_push, class: "pull-left" - .prepend-left-20 - = f.label :developers_can_push, "Developers can push", class: "label-light append-bottom-0" - %p.light.append-bottom-0 - Allow developers to push to this branch - - .form-group - = f.check_box :developers_can_merge, class: "pull-left" - .prepend-left-20 - = f.label :developers_can_merge, "Developers can merge", class: "label-light append-bottom-0" - %p.light.append-bottom-0 - Allow developers to accept merge requests to this branch - = f.submit "Protect", class: "btn-create btn protect-branch-btn", disabled: true - - %hr = render "branches_list" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index dd1cf680cfa..a666d07e9eb 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -43,6 +43,10 @@ %li = link_to 'Contribution guide', contribution_guide_path(@project) + - if @repository.gitlab_ci_yml + %li + = link_to 'CI configuration', ci_configuration_path(@project) + - if current_user && can_push_branch?(@project, @project.default_branch) - unless @repository.changelog %li.missing diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml index a3a4bd4f752..84da16b6bb1 100644 --- a/app/views/projects/tree/_tree_commit_column.html.haml +++ b/app/views/projects/tree/_tree_commit_column.html.haml @@ -1,2 +1,2 @@ %span.str-truncated - = link_to_gfm commit.title, namespace_project_commit_path(@project.namespace, @project, commit.id), class: "tree-commit-link" + = link_to_gfm commit.full_title, namespace_project_commit_path(@project.namespace, @project, commit.id), class: "tree-commit-link" diff --git a/app/views/projects/update.js.haml b/app/views/projects/update.js.haml index 7d9bd08385a..dcf1f767bf7 100644 --- a/app/views/projects/update.js.haml +++ b/app/views/projects/update.js.haml @@ -6,4 +6,4 @@ $(".project-edit-errors").html("#{escape_javascript(render('errors'))}"); $('.save-project-loader').hide(); $('.project-edit-container').show(); - $('.project-edit-content .btn-save').enable(); + $('.edit-project .btn-save').enable(); diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 797a1a59e9f..643f7c589e6 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -18,9 +18,14 @@ .error-alert .help-block - To link to a (new) page, simply type - %code [Link Title](page-slug) - \. + = succeed '.' do + To link to a (new) page, simply type + %code [Link Title](page-slug) + + = succeed '.' do + More examples are in the + = link_to 'documentation', help_page_path("user/project/markdown", anchor: "wiki-specific-markdown") + .form-group = f.label :commit_message, class: 'control-label' .col-sm-10= f.text_field :message, class: 'form-control', rows: 18 diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml index 8163aff43b6..e0400083870 100644 --- a/app/views/search/results/_note.html.haml +++ b/app/views/search/results/_note.html.haml @@ -1,6 +1,7 @@ - project = note.project - note_url = Gitlab::UrlBuilder.build(note) -- noteable_identifier = note.noteable.try(:iid) || note.noteable.id +- noteable_identifier = note.noteable.try(:iid) || note.noteable.try(:id) + .search-result-row %h5.note-search-caption.str-truncated %i.fa.fa-comment @@ -10,7 +11,10 @@ · - if note.for_commit? - = link_to "Commit #{truncate_sha(note.commit_id)}", note_url + = link_to_if(noteable_identifier, "Commit #{truncate_sha(note.commit_id)}", note_url) do + = truncate_sha(note.commit_id) + %span.light Commit deleted + - else %span #{note.noteable_type.titleize} ##{noteable_identifier} · diff --git a/app/views/shared/icons/_icon_status_cancel.svg b/app/views/shared/icons/_icon_status_cancel.svg index 6a0bc1490c4..fd1ebbcbabd 100644 --- a/app/views/shared/icons/_icon_status_cancel.svg +++ b/app/views/shared/icons/_icon_status_cancel.svg @@ -1,12 +1,6 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" xmlns:xlink="http://www.w3.org/1999/xlink"> - <defs> - <circle id="a" cx="7" cy="7" r="7"/> - <mask id="b" width="14" height="14" x="0" y="0" fill="white"> - <use xlink:href="#a"/> - </mask> - </defs> - <g fill="none" fill-rule="evenodd"> - <use stroke="#5C5C5C" stroke-width="2" mask="url(#b)" xlink:href="#a"/> - <rect width="10" height="1" x="2" y="6.5" fill="#5C5C5C" transform="rotate(45 7 7)" rx=".3"/> +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"> + <g fill="#5C5C5C" fill-rule="evenodd"> + <path d="M12.5,7 C12.5,3.96243388 10.0375661,1.5 7,1.5 C3.96243388,1.5 1.5,3.96243388 1.5,7 C1.5,10.0375661 3.96243388,12.5 7,12.5 C10.0375661,12.5 12.5,10.0375661 12.5,7 Z M0,7 C0,3.13400675 3.13400675,0 7,0 C10.8659932,0 14,3.13400675 14,7 C14,10.8659932 10.8659932,14 7,14 C3.13400675,14 0,10.8659932 0,7 Z"/> + <rect width="8" height="2" x="3" y="6" transform="rotate(45 7 7)" rx=".5"/> </g> </svg> diff --git a/app/views/shared/icons/_icon_status_failed.svg b/app/views/shared/icons/_icon_status_failed.svg index c41ca18cae7..e56e0887416 100644 --- a/app/views/shared/icons/_icon_status_failed.svg +++ b/app/views/shared/icons/_icon_status_failed.svg @@ -1,12 +1,6 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" xmlns:xlink="http://www.w3.org/1999/xlink"> - <defs> - <circle id="a" cx="7" cy="7" r="7"/> - <mask id="b" width="14" height="14" x="0" y="0" fill="white"> - <use xlink:href="#a"/> - </mask> - </defs> - <g fill="none" fill-rule="evenodd"> - <use stroke="#D22852" stroke-width="2" mask="url(#b)" xlink:href="#a"/> - <path fill="#D22852" d="M7.5,6.5 L7.5,4.30578971 C7.5,4.12531853 7.36809219,4 7.20537567,4 L6.79462433,4 C6.63904572,4 6.5,4.13690672 6.5,4.30578971 L6.5,6.5 L4.30578971,6.5 C4.12531853,6.5 4,6.63190781 4,6.79462433 L4,7.20537567 C4,7.36095428 4.13690672,7.5 4.30578971,7.5 L6.5,7.5 L6.5,9.69421029 C6.5,9.87468147 6.63190781,10 6.79462433,10 L7.20537567,10 C7.36095428,10 7.5,9.86309328 7.5,9.69421029 L7.5,7.5 L9.69421029,7.5 C9.87468147,7.5 10,7.36809219 10,7.20537567 L10,6.79462433 C10,6.63904572 9.86309328,6.5 9.69421029,6.5 L7.5,6.5 Z" transform="rotate(45 7 7)"/> +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"> + <g fill="#D22852" fill-rule="evenodd"> + <path d="M12.5,7 C12.5,3.96243388 10.0375661,1.5 7,1.5 C3.96243388,1.5 1.5,3.96243388 1.5,7 C1.5,10.0375661 3.96243388,12.5 7,12.5 C10.0375661,12.5 12.5,10.0375661 12.5,7 Z M0,7 C0,3.13400675 3.13400675,0 7,0 C10.8659932,0 14,3.13400675 14,7 C14,10.8659932 10.8659932,14 7,14 C3.13400675,14 0,10.8659932 0,7 Z"/> + <path d="M7.72916667,6.27083333 L7.72916667,4.28939247 C7.72916667,4.12531853 7.59703895,4 7.43405116,4 L6.56594884,4 C6.40541585,4 6.27083333,4.12956542 6.27083333,4.28939247 L6.27083333,6.27083333 L4.28939247,6.27083333 C4.12531853,6.27083333 4,6.40296105 4,6.56594884 L4,7.43405116 C4,7.59458415 4.12956542,7.72916667 4.28939247,7.72916667 L6.27083333,7.72916667 L6.27083333,9.71060753 C6.27083333,9.87468147 6.40296105,10 6.56594884,10 L7.43405116,10 C7.59458415,10 7.72916667,9.87043458 7.72916667,9.71060753 L7.72916667,7.72916667 L9.71060753,7.72916667 C9.87468147,7.72916667 10,7.59703895 10,7.43405116 L10,6.56594884 C10,6.40541585 9.87043458,6.27083333 9.71060753,6.27083333 L7.72916667,6.27083333 Z" transform="rotate(-45 7 7)"/> </g> </svg> diff --git a/app/views/shared/icons/_icon_status_pending.svg b/app/views/shared/icons/_icon_status_pending.svg index 035cd8b4ccc..117f0367161 100644 --- a/app/views/shared/icons/_icon_status_pending.svg +++ b/app/views/shared/icons/_icon_status_pending.svg @@ -1,13 +1,6 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" xmlns:xlink="http://www.w3.org/1999/xlink"> - <defs> - <circle id="a" cx="7" cy="7" r="7"/> - <mask id="b" width="14" height="14" x="0" y="0" fill="white"> - <use xlink:href="#a"/> - </mask> - </defs> - <g fill="none" fill-rule="evenodd"> - <use stroke="#E75E40" stroke-width="2" mask="url(#b)" xlink:href="#a"/> - <rect width="1" height="4" x="5" y="5" fill="#E75E40" rx=".3"/> - <rect width="1" height="4" x="8" y="5" fill="#E75E40" rx=".3"/> +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"> + <g fill="#E75E40" fill-rule="evenodd"> + <path d="M12.5,7 C12.5,3.96243388 10.0375661,1.5 7,1.5 C3.96243388,1.5 1.5,3.96243388 1.5,7 C1.5,10.0375661 3.96243388,12.5 7,12.5 C10.0375661,12.5 12.5,10.0375661 12.5,7 Z M0,7 C0,3.13400675 3.13400675,0 7,0 C10.8659932,0 14,3.13400675 14,7 C14,10.8659932 10.8659932,14 7,14 C3.13400675,14 0,10.8659932 0,7 Z"/> + <path d="M4.69999981,5.30065012 C4.69999981,5.13460564 4.83842754,5 5.00354719,5 L5.89645243,5 C6.06409702,5 6.19999981,5.13308716 6.19999981,5.30065012 L6.19999981,8.69934988 C6.19999981,8.86539436 6.06157207,9 5.89645243,9 L5.00354719,9 C4.8359026,9 4.69999981,8.86691284 4.69999981,8.69934988 L4.69999981,5.30065012 Z M7.69999981,5.30065012 C7.69999981,5.13460564 7.83842754,5 8.00354719,5 L8.89645243,5 C9.06409702,5 9.19999981,5.13308716 9.19999981,5.30065012 L9.19999981,8.69934988 C9.19999981,8.86539436 9.06157207,9 8.89645243,9 L8.00354719,9 C7.8359026,9 7.69999981,8.86691284 7.69999981,8.69934988 L7.69999981,5.30065012 Z"/> </g> </svg> diff --git a/app/views/shared/icons/_icon_status_running.svg b/app/views/shared/icons/_icon_status_running.svg index a48b3a25099..920d7952eb5 100644 --- a/app/views/shared/icons/_icon_status_running.svg +++ b/app/views/shared/icons/_icon_status_running.svg @@ -1,12 +1,6 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" xmlns:xlink="http://www.w3.org/1999/xlink"> - <defs> - <circle id="a" cx="7" cy="7" r="7"/> - <mask id="b" width="14" height="14" x="0" y="0" fill="white"> - <use xlink:href="#a"/> - </mask> - </defs> - <g fill="none" fill-rule="evenodd"> - <use stroke="#2D9FD8" stroke-width="2" mask="url(#b)" xlink:href="#a"/> - <path fill="#2D9FD8" d="M7,3.00800862 C9.09023405,3.13960661 10.7448145,4.87657932 10.7448145,7 C10.7448145,9.209139 8.95395346,11 6.74481446,11 C5.4560962,11 4.30972054,10.3905589 3.57817301,9.44416214 L7,7 L7,3.00800862 Z"/> +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"> + <g fill="#2D9FD8" fill-rule="evenodd"> + <path d="M12.5,7 C12.5,3.96243388 10.0375661,1.5 7,1.5 C3.96243388,1.5 1.5,3.96243388 1.5,7 C1.5,10.0375661 3.96243388,12.5 7,12.5 C10.0375661,12.5 12.5,10.0375661 12.5,7 Z M0,7 C0,3.13400675 3.13400675,0 7,0 C10.8659932,0 14,3.13400675 14,7 C14,10.8659932 10.8659932,14 7,14 C3.13400675,14 0,10.8659932 0,7 Z"/> + <path d="M7,3 C9.209139,3 11,4.790861 11,7 C11,9.209139 9.209139,11 7,11 C5.65802855,11 4.47040669,10.3391508 3.74481446,9.32513253 L7,7 L7,3 L7,3 Z"/> </g> </svg> diff --git a/app/views/shared/icons/_icon_status_success.svg b/app/views/shared/icons/_icon_status_success.svg index 260eab013a3..67b378b3571 100644 --- a/app/views/shared/icons/_icon_status_success.svg +++ b/app/views/shared/icons/_icon_status_success.svg @@ -1,15 +1,6 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" xmlns:xlink="http://www.w3.org/1999/xlink"> - <defs> - <circle id="a" cx="7" cy="7" r="7"/> - <mask id="b" width="14" height="14" x="0" y="0" fill="white"> - <use xlink:href="#a"/> - </mask> - </defs> - <g fill="none" fill-rule="evenodd"> - <use stroke="#31AF64" stroke-width="2" mask="url(#b)" xlink:href="#a"/> - <g fill="#31AF64" transform="rotate(45 -.13 10.953)"> - <rect width="1" height="5" x="2" rx=".3"/> - <rect width="3" height="1" y="4" rx=".3"/> - </g> +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"> + <g fill="#31AF64" fill-rule="evenodd"> + <path d="M12.5,7 C12.5,3.96243388 10.0375661,1.5 7,1.5 C3.96243388,1.5 1.5,3.96243388 1.5,7 C1.5,10.0375661 3.96243388,12.5 7,12.5 C10.0375661,12.5 12.5,10.0375661 12.5,7 Z M0,7 C0,3.13400675 3.13400675,0 7,0 C10.8659932,0 14,3.13400675 14,7 C14,10.8659932 10.8659932,14 7,14 C3.13400675,14 0,10.8659932 0,7 Z"/> + <path d="M7.29166667,7.875 L5.54840803,7.875 C5.38293028,7.875 5.25,8.00712771 5.25,8.17011551 L5.25,9.03821782 C5.25,9.19875081 5.38360183,9.33333333 5.54840803,9.33333333 L8.24853534,9.33333333 C8.52035522,9.33333333 8.75,9.11228506 8.75,8.83960819 L8.75,8.46475969 L8.75,4.07392947 C8.75,3.92144267 8.61787229,3.79166667 8.45488449,3.79166667 L7.58678218,3.79166667 C7.42624919,3.79166667 7.29166667,3.91804003 7.29166667,4.07392947 L7.29166667,7.875 Z" transform="rotate(45 7 6.563)"/> </g> </svg> diff --git a/app/views/shared/icons/_icon_status_warning.svg b/app/views/shared/icons/_icon_status_warning.svg index d47e7a1c93f..d0ad4bd65b1 100644 --- a/app/views/shared/icons/_icon_status_warning.svg +++ b/app/views/shared/icons/_icon_status_warning.svg @@ -1,15 +1,6 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" xmlns:xlink="http://www.w3.org/1999/xlink"> - <defs> - <circle id="a" cx="7" cy="7" r="7"/> - <mask id="b" width="14" height="14" x="0" y="0" fill="white"> - <use xlink:href="#a"/> - </mask> - </defs> - <g fill="none" fill-rule="evenodd"> - <g fill="#FF8A24" transform="translate(6 3)"> - <rect width="2" height="5" rx=".5"/> - <rect width="2" height="2" y="6" rx=".5"/> - </g> - <use stroke="#FF8A24" stroke-width="2" mask="url(#b)" xlink:href="#a"/> +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"> + <g fill="#FF8A24" fill-rule="evenodd"> + <path d="M12.5,7 C12.5,3.96243388 10.0375661,1.5 7,1.5 C3.96243388,1.5 1.5,3.96243388 1.5,7 C1.5,10.0375661 3.96243388,12.5 7,12.5 C10.0375661,12.5 12.5,10.0375661 12.5,7 Z M0,7 C0,3.13400675 3.13400675,0 7,0 C10.8659932,0 14,3.13400675 14,7 C14,10.8659932 10.8659932,14 7,14 C3.13400675,14 0,10.8659932 0,7 Z"/> + <path d="M6,3.49769878 C6,3.22282734 6.21403503,3 6.50468445,3 L7.49531555,3 C7.77404508,3 8,3.21484375 8,3.49769878 L8,7.50230122 C8,7.77717266 7.78596497,8 7.49531555,8 L6.50468445,8 C6.22595492,8 6,7.78515625 6,7.50230122 L6,3.49769878 Z M6,9.50468445 C6,9.22595492 6.21403503,9 6.50468445,9 L7.49531555,9 C7.77404508,9 8,9.21403503 8,9.50468445 L8,10.4953156 C8,10.7740451 7.78596497,11 7.49531555,11 L6.50468445,11 C6.22595492,11 6,10.785965 6,10.4953156 L6,9.50468445 Z"/> </g> </svg> diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml index 0acb8253139..4e280c371ac 100644 --- a/app/views/shared/issuable/_label_page_default.html.haml +++ b/app/views/shared/issuable/_label_page_default.html.haml @@ -4,7 +4,7 @@ - filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels') .dropdown-page-one = dropdown_title(title) - = dropdown_filter(filter_placeholder, search_id: "label-name") + = dropdown_filter(filter_placeholder) = dropdown_content - if @project && show_footer = dropdown_footer do diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 2585ed9360b..470dac6d75b 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -19,7 +19,7 @@ = f.label :token, "Secret Token", class: 'label-light' = f.text_field :token, class: "form-control", placeholder: '' %p.help-block - Use this token to validate received payloads + Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header. .form-group = f.label :url, "Trigger", class: 'label-light' %ul.list-unstyled diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index f2649e38eb3..842eebdea9e 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -21,31 +21,35 @@ class EmailReceiverWorker return unless raw.present? can_retry = false - reason = nil - - case e - when Gitlab::Email::Receiver::SentNotificationNotFoundError - reason = "We couldn't figure out what the email is in reply to. Please create your comment through the web interface." - when Gitlab::Email::Receiver::EmptyEmailError - can_retry = true - reason = "It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies." - when Gitlab::Email::Receiver::AutoGeneratedEmailError - reason = "The email was marked as 'auto generated', which we can't accept. Please create your comment through the web interface." - when Gitlab::Email::Receiver::UserNotFoundError - reason = "We couldn't figure out what user corresponds to the email. Please create your comment through the web interface." - when Gitlab::Email::Receiver::UserBlockedError - reason = "Your account has been blocked. If you believe this is in error, contact a staff member." - when Gitlab::Email::Receiver::UserNotAuthorizedError - reason = "You are not allowed to respond to the thread you are replying to. If you believe this is in error, contact a staff member." - when Gitlab::Email::Receiver::NoteableNotFoundError - reason = "The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member." - when Gitlab::Email::Receiver::InvalidNoteError - can_retry = true - reason = e.message - else - return + reason = + case e + when Gitlab::Email::UnknownIncomingEmail + "We couldn't figure out what the email is for. Please create your issue or comment through the web interface." + when Gitlab::Email::SentNotificationNotFoundError + "We couldn't figure out what the email is in reply to. Please create your comment through the web interface." + when Gitlab::Email::ProjectNotFound + "We couldn't find the project. Please check if there's any typo." + when Gitlab::Email::EmptyEmailError + can_retry = true + "It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies." + when Gitlab::Email::AutoGeneratedEmailError + "The email was marked as 'auto generated', which we can't accept. Please create your comment through the web interface." + when Gitlab::Email::UserNotFoundError + "We couldn't figure out what user corresponds to the email. Please create your comment through the web interface." + when Gitlab::Email::UserBlockedError + "Your account has been blocked. If you believe this is in error, contact a staff member." + when Gitlab::Email::UserNotAuthorizedError + "You are not allowed to perform this action. If you believe this is in error, contact a staff member." + when Gitlab::Email::NoteableNotFoundError + "The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member." + when Gitlab::Email::InvalidNoteError, + Gitlab::Email::InvalidIssueError + can_retry = true + e.message + end + + if reason + EmailRejectionMailer.rejection(reason, raw, can_retry).deliver_later end - - EmailRejectionMailer.rejection(reason, raw, can_retry).deliver_later end end diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 0b6a01a3200..c6a5af2809a 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -33,25 +33,14 @@ class EmailsOnPushWorker reverse_compare = false if action == :push - merge_base_sha = project.merge_base_commit(before_sha, after_sha).try(:sha) - compare = Gitlab::Git::Compare.new(project.repository.raw_repository, before_sha, after_sha) - - diff_refs = Gitlab::Diff::DiffRefs.new( - base_sha: merge_base_sha, - start_sha: before_sha, - head_sha: after_sha - ) + compare = CompareService.new.execute(project, before_sha, project, after_sha) + diff_refs = compare.diff_refs return false if compare.same if compare.commits.empty? - compare = Gitlab::Git::Compare.new(project.repository.raw_repository, after_sha, before_sha) - - diff_refs = Gitlab::Diff::DiffRefs.new( - base_sha: merge_base_sha, - start_sha: after_sha, - head_sha: before_sha - ) + compare = CompareService.new.execute(project, after_sha, project, before_sha) + diff_refs = compare.diff_refs reverse_compare = true diff --git a/app/workers/import_export_project_cleanup_worker.rb b/app/workers/import_export_project_cleanup_worker.rb new file mode 100644 index 00000000000..72e3a9ae734 --- /dev/null +++ b/app/workers/import_export_project_cleanup_worker.rb @@ -0,0 +1,9 @@ +class ImportExportProjectCleanupWorker + include Sidekiq::Worker + + sidekiq_options queue: :default + + def perform + ImportExportCleanUpService.new.execute + end +end diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index 605ec4f04e5..19f38358eb5 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -141,8 +141,10 @@ class IrkerWorker end def files_count(commit) - files = "#{commit.diffs.real_size} file" - files += 's' if commit.diffs.count > 1 + diffs = commit.raw_diffs(deltas_only: true) + + files = "#{diffs.real_size} file" + files += 's' if diffs.size > 1 files end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 09035a7cf2d..a9a2b716005 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -10,6 +10,10 @@ class PostReceive log("Check gitlab.yml config for correct repositories.storages values. No repository storage path matches \"#{repo_path}\"") 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_path, identifier, changes) if post_received.project.nil? diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb index b51c6a266c9..3062301a9b1 100644 --- a/app/workers/project_destroy_worker.rb +++ b/app/workers/project_destroy_worker.rb @@ -12,6 +12,6 @@ class ProjectDestroyWorker user = User.find(user_id) - ::Projects::DestroyService.new(project, user, params).execute + ::Projects::DestroyService.new(project, user, params.symbolize_keys).execute end end diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index f7604e48f83..d69d6037053 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -4,7 +4,7 @@ class RepositoryForkWorker sidekiq_options queue: :gitlab_shell - def perform(project_id, source_path, target_path) + def perform(project_id, forked_from_repository_storage_path, source_path, target_path) project = Project.find_by_id(project_id) unless project.present? @@ -12,7 +12,8 @@ class RepositoryForkWorker return end - result = gitlab_shell.fork_repository(project.repository_storage_path, source_path, target_path) + result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_path, + project.repository_storage_path, target_path) unless result logger.error("Unable to fork project #{project_id} for repository #{source_path} -> #{target_path}") project.mark_import_as_failed('The project could not be forked.') diff --git a/app/workers/gitlab_remove_project_export_worker.rb b/app/workers/requests_profiles_worker.rb index 1d91897d520..9dd228a2483 100644 --- a/app/workers/gitlab_remove_project_export_worker.rb +++ b/app/workers/requests_profiles_worker.rb @@ -1,9 +1,9 @@ -class GitlabRemoveProjectExportWorker +class RequestsProfilesWorker include Sidekiq::Worker sidekiq_options queue: :default def perform - Project.remove_gitlab_exports! + Gitlab::RequestProfiler.remove_all_profiles end end diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 293f2b71d65..74325872b09 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -68,6 +68,25 @@ :why: https://opensource.org/licenses/BSD-2-Clause :versions: [] :when: 2016-05-02 05:55:09.796363000 Z +- - :whitelist + - LGPLv2+ + - :who: Stan Hu + :why: Equivalent to LGPLv2 + :versions: [] + :when: 2016-06-07 17:14:10.907682000 Z +- - :whitelist + - Artistic 2.0 + - :who: Josh Frye + :why: Disk/mount information display on Admin pages + :versions: [] + :when: 2016-06-29 16:32:45.432113000 Z +- - :whitelist + - Simplified BSD + - :who: Douwe Maan + :why: https://opensource.org/licenses/BSD-2-Clause + :versions: [] + :when: 2016-07-26 21:24:07.248480000 Z + # LICENSE BLACKLIST - - :blacklist @@ -175,15 +194,3 @@ :why: https://github.com/jmcnevin/rubypants/blob/master/LICENSE.rdoc :versions: [] :when: 2016-05-02 05:56:50.696858000 Z -- - :whitelist - - LGPLv2+ - - :who: Stan Hu - :why: Equivalent to LGPLv2 - :versions: [] - :when: 2016-06-07 17:14:10.907682000 Z -- - :whitelist - - Artistic 2.0 - - :who: Josh Frye - :why: Disk/mount information display on Admin pages - :versions: [] - :when: 2016-06-29 16:32:45.432113000 Z diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 86f55210487..deac3b0f0f9 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -287,9 +287,12 @@ Settings.cron_jobs['admin_email_worker']['job_class'] = 'AdminEmailWorker' Settings.cron_jobs['repository_archive_cache_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['repository_archive_cache_worker']['cron'] ||= '0 * * * *' Settings.cron_jobs['repository_archive_cache_worker']['job_class'] = 'RepositoryArchiveCacheWorker' -Settings.cron_jobs['gitlab_remove_project_export_worker'] ||= Settingslogic.new({}) -Settings.cron_jobs['gitlab_remove_project_export_worker']['cron'] ||= '0 * * * *' -Settings.cron_jobs['gitlab_remove_project_export_worker']['job_class'] = 'GitlabRemoveProjectExportWorker' +Settings.cron_jobs['import_export_project_cleanup_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['import_export_project_cleanup_worker']['cron'] ||= '0 * * * *' +Settings.cron_jobs['import_export_project_cleanup_worker']['job_class'] = 'ImportExportProjectCleanupWorker' +Settings.cron_jobs['requests_profiles_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['requests_profiles_worker']['cron'] ||= '0 0 * * *' +Settings.cron_jobs['requests_profiles_worker']['job_class'] = 'RequestsProfilesWorker' # # GitLab Shell diff --git a/config/initializers/6_validations.rb b/config/initializers/6_validations.rb index 37746968675..d92f64e1647 100644 --- a/config/initializers/6_validations.rb +++ b/config/initializers/6_validations.rb @@ -26,4 +26,4 @@ def validate_storages end end -validate_storages unless Rails.env.test? +validate_storages unless Rails.env.test? || ENV['SKIP_STORAGE_VALIDATION'] == 'true' diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 73977341b73..a0a8f88584c 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -100,6 +100,9 @@ Devise.setup do |config| # secure: true in order to force SSL only cookies. # config.cookie_options = {} + # Send a notification email when the user's password is changed + config.send_password_change_notification = true + # ==> Configuration for :validatable # Range for password length. Default is 6..128. config.password_length = 8..128 diff --git a/config/initializers/metrics.rb b/config/initializers/metrics.rb index c4266ab8ba5..cc8208db3c1 100644 --- a/config/initializers/metrics.rb +++ b/config/initializers/metrics.rb @@ -136,7 +136,18 @@ if Gitlab::Metrics.enabled? config.instrument_instance_methods(Rouge::Plugins::Redcarpet) config.instrument_instance_methods(Rouge::Formatters::HTMLGitlab) + [:XML, :HTML].each do |namespace| + namespace_mod = Nokogiri.const_get(namespace) + + config.instrument_methods(namespace_mod) + config.instrument_methods(namespace_mod::Document) + end + config.instrument_methods(Rinku) + config.instrument_instance_methods(Repository) + + config.instrument_methods(Gitlab::Highlight) + config.instrument_instance_methods(Gitlab::Highlight) end GC::Profiler.enable diff --git a/config/initializers/request_profiler.rb b/config/initializers/request_profiler.rb new file mode 100644 index 00000000000..a9aa802681a --- /dev/null +++ b/config/initializers/request_profiler.rb @@ -0,0 +1,5 @@ +require 'gitlab/request_profiler/middleware' + +Rails.application.configure do |config| + config.middleware.use(Gitlab::RequestProfiler::Middleware) +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 5e839327e7a..cf49ec2194c 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -7,6 +7,7 @@ Sidekiq.configure_server do |config| config.server_middleware do |chain| chain.add Gitlab::SidekiqMiddleware::ArgumentsLogger if ENV['SIDEKIQ_LOG_ARGUMENTS'] chain.add Gitlab::SidekiqMiddleware::MemoryKiller if ENV['SIDEKIQ_MEMORY_KILLER_MAX_RSS'] + chain.add Gitlab::SidekiqMiddleware::RequestStoreMiddleware unless ENV['SIDEKIQ_REQUEST_STORE'] == '0' end # Sidekiq-cron: load recurring jobs from gitlab.yml diff --git a/config/initializers/trusted_proxies.rb b/config/initializers/trusted_proxies.rb index df4a933e22f..cd869657c53 100644 --- a/config/initializers/trusted_proxies.rb +++ b/config/initializers/trusted_proxies.rb @@ -7,10 +7,18 @@ module Rack class Request def trusted_proxy?(ip) Rails.application.config.action_dispatch.trusted_proxies.any? { |proxy| proxy === ip } + rescue IPAddr::InvalidAddressError + false end end end +gitlab_trusted_proxies = Array(Gitlab.config.gitlab.trusted_proxies).map do |proxy| + begin + IPAddr.new(proxy) + rescue IPAddr::InvalidAddressError + end +end.compact + Rails.application.config.action_dispatch.trusted_proxies = ( - [ '127.0.0.1', '::1' ] + Array(Gitlab.config.gitlab.trusted_proxies) -).map { |proxy| IPAddr.new(proxy) } + [ '127.0.0.1', '::1' ] + gitlab_trusted_proxies) diff --git a/config/routes.rb b/config/routes.rb index 47599441cad..99a2cd884fe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,10 +42,9 @@ Rails.application.routes.draw do resource :lint, only: [:show, :create] - resources :projects do + resources :projects, only: [:index, :show] do member do get :status, to: 'projects#badge' - get :integration end end @@ -141,13 +140,13 @@ Rails.application.routes.draw do get :jobs end - resource :gitlab, only: [:create, :new], controller: :gitlab do + resource :gitlab, only: [:create], controller: :gitlab do get :status get :callback get :jobs end - resource :bitbucket, only: [:create, :new], controller: :bitbucket do + resource :bitbucket, only: [:create], controller: :bitbucket do get :status get :callback get :jobs @@ -240,7 +239,6 @@ Rails.application.routes.draw do get :projects get :keys get :groups - put :team_update put :block put :unblock put :unlock @@ -278,6 +276,7 @@ Rails.application.routes.draw do resource :health_check, controller: 'health_check', only: [:show] resource :background_jobs, controller: 'background_jobs', only: [:show] resource :system_info, controller: 'system_info', only: [:show] + resources :requests_profiles, only: [:index, :show], param: :name, constraints: { name: /.+\.html/ } resources :namespaces, path: '/projects', constraints: { id: /[a-zA-Z.0-9_\-]+/ }, only: [] do root to: 'projects#index', as: :projects @@ -297,7 +296,7 @@ Rails.application.routes.draw do end end - resource :appearances, path: 'appearance' do + resource :appearances, only: [:show, :create, :update], path: 'appearance' do member do get :preview delete :logo @@ -306,7 +305,7 @@ Rails.application.routes.draw do end resource :application_settings, only: [:show, :update] do - resources :services + resources :services, only: [:index, :edit, :update] put :reset_runners_token put :reset_health_check_token put :clear_repository_check_states @@ -343,7 +342,7 @@ Rails.application.routes.draw do end scope module: :profiles do - resource :account, only: [:show, :update] do + resource :account, only: [:show] do member do delete :unlink end @@ -355,7 +354,7 @@ Rails.application.routes.draw do end end resource :preferences, only: [:show, :update] - resources :keys + resources :keys, only: [:index, :show, :new, :create, :destroy] resources :emails, only: [:index, :create, :destroy] resource :avatar, only: [:destroy] @@ -639,13 +638,17 @@ Rails.application.routes.draw do get '/compare/:from...:to', to: 'compare#show', as: 'compare', constraints: { from: /.+/, to: /.+/ } - resources :network, only: [:show], constraints: { id: /(?:[^.]|\.(?!json$))+/, format: /json/ } + # Don't use format parameter as file extension (old 3.0.x behavior) + # See http://guides.rubyonrails.org/routing.html#route-globbing-and-wildcard-segments + scope format: false do + resources :network, only: [:show], constraints: { id: Gitlab::Regex.git_reference_regex } - resources :graphs, only: [:show], constraints: { id: /(?:[^.]|\.(?!json$))+/, format: /json/ } do - member do - get :commits - get :ci - get :languages + resources :graphs, only: [:show], constraints: { id: Gitlab::Regex.git_reference_regex } do + member do + get :commits + get :ci + get :languages + end end end @@ -672,7 +675,7 @@ Rails.application.routes.draw do post '/wikis/*id/markdown_preview', to: 'wikis#markdown_preview', constraints: WIKI_SLUG_ID, as: 'wiki_markdown_preview' end - resource :repository, only: [:show, :create] do + resource :repository, only: [:create] do member do get 'archive', constraints: { format: Gitlab::Regex.archive_formats_regex } end @@ -754,7 +757,7 @@ Rails.application.routes.draw do end end - resources :environments, only: [:index, :show, :new, :create, :destroy] + resources :environments resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do collection do @@ -794,7 +797,7 @@ Rails.application.routes.draw do end end - resources :labels, constraints: { id: /\d+/ } do + resources :labels, except: [:show], constraints: { id: /\d+/ } do collection do post :generate post :set_priorities @@ -819,7 +822,7 @@ Rails.application.routes.draw do end end - resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do + resources :project_members, except: [:show, :new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do collection do delete :leave diff --git a/db/fixtures/development/04_project.rb b/db/fixtures/development/04_project.rb index b4639999967..e3316ecdb6c 100644 --- a/db/fixtures/development/04_project.rb +++ b/db/fixtures/development/04_project.rb @@ -20,7 +20,6 @@ Sidekiq::Testing.inline! do 'https://github.com/airbnb/javascript.git', 'https://github.com/tessalt/echo-chamber-js.git', 'https://github.com/atom/atom.git', - 'https://github.com/ipselon/react-ui-builder.git', 'https://github.com/mattermost/platform.git', 'https://github.com/purifycss/purifycss.git', 'https://github.com/facebook/nuclide.git', diff --git a/db/fixtures/development/14_builds.rb b/db/fixtures/development/14_builds.rb index 124704cb451..e65abe4ef77 100644 --- a/db/fixtures/development/14_builds.rb +++ b/db/fixtures/development/14_builds.rb @@ -1,6 +1,6 @@ class Gitlab::Seeder::Builds STAGES = %w[build notify_build test notify_test deploy notify_deploy] - + def initialize(project) @project = project end @@ -8,26 +8,26 @@ class Gitlab::Seeder::Builds def seed! pipelines.each do |pipeline| begin - build_create!(pipeline, name: 'build:linux', stage: 'build') - build_create!(pipeline, name: 'build:osx', stage: 'build') + build_create!(pipeline, name: 'build:linux', stage: 'build', status_event: :success) + build_create!(pipeline, name: 'build:osx', stage: 'build', status_event: :success) - build_create!(pipeline, name: 'slack post build', stage: 'notify_build') + build_create!(pipeline, name: 'slack post build', stage: 'notify_build', status_event: :success) - build_create!(pipeline, name: 'rspec:linux', stage: 'test') - build_create!(pipeline, name: 'rspec:windows', stage: 'test') - build_create!(pipeline, name: 'rspec:windows', stage: 'test') - build_create!(pipeline, name: 'rspec:osx', stage: 'test') - build_create!(pipeline, name: 'spinach:linux', stage: 'test') - build_create!(pipeline, name: 'spinach:osx', stage: 'test') - build_create!(pipeline, name: 'cucumber:linux', stage: 'test') - build_create!(pipeline, name: 'cucumber:osx', stage: 'test') + build_create!(pipeline, name: 'rspec:linux', stage: 'test', status_event: :success) + build_create!(pipeline, name: 'rspec:windows', stage: 'test', status_event: :success) + build_create!(pipeline, name: 'rspec:windows', stage: 'test', status_event: :success) + build_create!(pipeline, name: 'rspec:osx', stage: 'test', status_event: :success) + build_create!(pipeline, name: 'spinach:linux', stage: 'test', status: :pending) + build_create!(pipeline, name: 'spinach:osx', stage: 'test', status_event: :cancel) + build_create!(pipeline, name: 'cucumber:linux', stage: 'test', status_event: :run) + build_create!(pipeline, name: 'cucumber:osx', stage: 'test', status_event: :drop) - build_create!(pipeline, name: 'slack post test', stage: 'notify_test') + build_create!(pipeline, name: 'slack post test', stage: 'notify_test', status_event: :success) - build_create!(pipeline, name: 'staging', stage: 'deploy', environment: 'staging') - build_create!(pipeline, name: 'production', stage: 'deploy', environment: 'production', when: 'manual') + build_create!(pipeline, name: 'staging', stage: 'deploy', environment: 'staging', status_event: :success) + build_create!(pipeline, name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :success) - commit_status_create!(pipeline, name: 'jenkins') + commit_status_create!(pipeline, name: 'jenkins', status: :success) print '.' rescue ActiveRecord::RecordInvalid @@ -48,7 +48,7 @@ class Gitlab::Seeder::Builds def build_create!(pipeline, opts = {}) attributes = build_attributes_for(pipeline, opts) - build = Ci::Build.new(attributes) + build = Ci::Build.create!(attributes) if opts[:name].start_with?('build') artifacts_cache_file(artifacts_archive_path) do |file| @@ -60,23 +60,20 @@ class Gitlab::Seeder::Builds end end - build.save! - build.update(status: build_status) - if %w(running success failed).include?(build.status) # We need to set build trace after saving a build (id required) build.trace = FFaker::Lorem.paragraphs(6).join("\n\n") end end - + def commit_status_create!(pipeline, opts = {}) attributes = commit_status_attributes_for(pipeline, opts) - GenericCommitStatus.create(attributes) + GenericCommitStatus.create!(attributes) end - + def commit_status_attributes_for(pipeline, opts) { name: 'test build', stage: 'test', stage_idx: stage_index(opts[:stage]), - ref: 'master', user: build_user, project: @project, pipeline: pipeline, + ref: 'master', tag: false, user: build_user, project: @project, pipeline: pipeline, created_at: Time.now, updated_at: Time.now }.merge(opts) end diff --git a/db/fixtures/development/16_protected_branches.rb b/db/fixtures/development/16_protected_branches.rb new file mode 100644 index 00000000000..103c7f9445c --- /dev/null +++ b/db/fixtures/development/16_protected_branches.rb @@ -0,0 +1,12 @@ +Gitlab::Seeder.quiet do + admin_user = User.find(1) + + Project.all.each do |project| + params = { + name: 'master' + } + + ProtectedBranches::CreateService.new(project, admin_user, params).execute + print '.' + end +end diff --git a/db/migrate/20160705054938_add_protected_branches_push_access.rb b/db/migrate/20160705054938_add_protected_branches_push_access.rb new file mode 100644 index 00000000000..f27295524e1 --- /dev/null +++ b/db/migrate/20160705054938_add_protected_branches_push_access.rb @@ -0,0 +1,17 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddProtectedBranchesPushAccess < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :protected_branch_push_access_levels do |t| + t.references :protected_branch, index: { name: "index_protected_branch_push_access" }, foreign_key: true, null: false + + # Gitlab::Access::MASTER == 40 + t.integer :access_level, default: 40, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160705054952_add_protected_branches_merge_access.rb b/db/migrate/20160705054952_add_protected_branches_merge_access.rb new file mode 100644 index 00000000000..32adfa266cd --- /dev/null +++ b/db/migrate/20160705054952_add_protected_branches_merge_access.rb @@ -0,0 +1,17 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddProtectedBranchesMergeAccess < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :protected_branch_merge_access_levels do |t| + t.references :protected_branch, index: { name: "index_protected_branch_merge_access" }, foreign_key: true, null: false + + # Gitlab::Access::MASTER == 40 + t.integer :access_level, default: 40, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160705055254_move_from_developers_can_merge_to_protected_branches_merge_access.rb b/db/migrate/20160705055254_move_from_developers_can_merge_to_protected_branches_merge_access.rb new file mode 100644 index 00000000000..1db0df92bec --- /dev/null +++ b/db/migrate/20160705055254_move_from_developers_can_merge_to_protected_branches_merge_access.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 MoveFromDevelopersCanMergeToProtectedBranchesMergeAccess < ActiveRecord::Migration + DOWNTIME = true + DOWNTIME_REASON = <<-HEREDOC + We're creating a `merge_access_level` for each `protected_branch`. If a user creates a `protected_branch` while this + is running, we might be left with a `protected_branch` _without_ an associated `merge_access_level`. The `protected_branches` + table must not change while this is running, so downtime is required. + + https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5081#note_13247410 + HEREDOC + + def up + execute <<-HEREDOC + INSERT into protected_branch_merge_access_levels (protected_branch_id, access_level, created_at, updated_at) + SELECT id, (CASE WHEN developers_can_merge THEN 30 ELSE 40 END), now(), now() + FROM protected_branches + HEREDOC + end + + def down + execute <<-HEREDOC + UPDATE protected_branches SET developers_can_merge = TRUE + WHERE id IN (SELECT protected_branch_id FROM protected_branch_merge_access_levels + WHERE access_level = 30); + HEREDOC + end +end diff --git a/db/migrate/20160705055308_move_from_developers_can_push_to_protected_branches_push_access.rb b/db/migrate/20160705055308_move_from_developers_can_push_to_protected_branches_push_access.rb new file mode 100644 index 00000000000..5c3e189bb5b --- /dev/null +++ b/db/migrate/20160705055308_move_from_developers_can_push_to_protected_branches_push_access.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 MoveFromDevelopersCanPushToProtectedBranchesPushAccess < ActiveRecord::Migration + DOWNTIME = true + DOWNTIME_REASON = <<-HEREDOC + We're creating a `push_access_level` for each `protected_branch`. If a user creates a `protected_branch` while this + is running, we might be left with a `protected_branch` _without_ an associated `push_access_level`. The `protected_branches` + table must not change while this is running, so downtime is required. + + https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5081#note_13247410 + HEREDOC + + def up + execute <<-HEREDOC + INSERT into protected_branch_push_access_levels (protected_branch_id, access_level, created_at, updated_at) + SELECT id, (CASE WHEN developers_can_push THEN 30 ELSE 40 END), now(), now() + FROM protected_branches + HEREDOC + end + + def down + execute <<-HEREDOC + UPDATE protected_branches SET developers_can_push = TRUE + WHERE id IN (SELECT protected_branch_id FROM protected_branch_push_access_levels + WHERE access_level = 30); + HEREDOC + end +end diff --git a/db/migrate/20160705055809_remove_developers_can_push_from_protected_branches.rb b/db/migrate/20160705055809_remove_developers_can_push_from_protected_branches.rb new file mode 100644 index 00000000000..52a9819c628 --- /dev/null +++ b/db/migrate/20160705055809_remove_developers_can_push_from_protected_branches.rb @@ -0,0 +1,19 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveDevelopersCanPushFromProtectedBranches < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # This is only required for `#down` + disable_ddl_transaction! + + DOWNTIME = false + + def up + remove_column :protected_branches, :developers_can_push, :boolean + end + + def down + add_column_with_default(:protected_branches, :developers_can_push, :boolean, default: false, allow_null: false) + end +end diff --git a/db/migrate/20160705055813_remove_developers_can_merge_from_protected_branches.rb b/db/migrate/20160705055813_remove_developers_can_merge_from_protected_branches.rb new file mode 100644 index 00000000000..4a7bde7f9f3 --- /dev/null +++ b/db/migrate/20160705055813_remove_developers_can_merge_from_protected_branches.rb @@ -0,0 +1,19 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveDevelopersCanMergeFromProtectedBranches < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # This is only required for `#down` + disable_ddl_transaction! + + DOWNTIME = false + + def up + remove_column :protected_branches, :developers_can_merge, :boolean + end + + def down + add_column_with_default(:protected_branches, :developers_can_merge, :boolean, default: false, allow_null: false) + end +end diff --git a/db/migrate/20160722221922_nullify_blank_type_on_notes.rb b/db/migrate/20160722221922_nullify_blank_type_on_notes.rb new file mode 100644 index 00000000000..c4b78e8e15c --- /dev/null +++ b/db/migrate/20160722221922_nullify_blank_type_on_notes.rb @@ -0,0 +1,9 @@ +class NullifyBlankTypeOnNotes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + execute "UPDATE notes SET type = NULL WHERE type = ''" + end +end diff --git a/db/migrate/20160725083350_add_external_url_to_enviroments.rb b/db/migrate/20160725083350_add_external_url_to_enviroments.rb new file mode 100644 index 00000000000..21a8abd310b --- /dev/null +++ b/db/migrate/20160725083350_add_external_url_to_enviroments.rb @@ -0,0 +1,9 @@ +class AddExternalUrlToEnviroments < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column(:environments, :external_url, :string) + end +end diff --git a/db/migrate/20160802010328_remove_builds_enable_index_on_projects.rb b/db/migrate/20160802010328_remove_builds_enable_index_on_projects.rb new file mode 100644 index 00000000000..5fd51cb65f1 --- /dev/null +++ b/db/migrate/20160802010328_remove_builds_enable_index_on_projects.rb @@ -0,0 +1,9 @@ +class RemoveBuildsEnableIndexOnProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + remove_index :projects, column: :builds_enabled if index_exists?(:projects, :builds_enabled) + end +end diff --git a/db/migrate/20160804150737_add_timestamps_to_members_again.rb b/db/migrate/20160804150737_add_timestamps_to_members_again.rb new file mode 100644 index 00000000000..6691ba57fbb --- /dev/null +++ b/db/migrate/20160804150737_add_timestamps_to_members_again.rb @@ -0,0 +1,21 @@ +# rubocop:disable all +# 20141121133009_add_timestamps_to_members.rb was meant to ensure that all +# rows in the members table had created_at and updated_at set, following an +# error in a previous migration. This failed to set all rows in at least one +# case: https://gitlab.com/gitlab-org/gitlab-ce/issues/20568 +# +# Why this happened is lost in the mists of time, so repeat the SQL query +# without speculation, just in case more than one person was affected. +class AddTimestampsToMembersAgain < ActiveRecord::Migration + DOWNTIME = false + + def up + execute "UPDATE members SET created_at = NOW() WHERE created_at IS NULL" + execute "UPDATE members SET updated_at = NOW() WHERE updated_at IS NULL" + end + + def down + # no change + end + +end diff --git a/db/schema.rb b/db/schema.rb index d541e1cccb7..71980a6d51f 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: 20160721081015) do +ActiveRecord::Schema.define(version: 20160804150737) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -84,7 +84,7 @@ ActiveRecord::Schema.define(version: 20160721081015) do t.string "health_check_access_token" t.boolean "send_user_confirmation_email", default: false t.integer "container_registry_token_expire_delay", default: 5 - t.boolean "user_default_external", default: false, null: false + t.boolean "user_default_external", default: false, null: false t.text "after_sign_up_text" t.string "repository_storage", default: "default" t.string "enabled_git_access_protocol" @@ -427,9 +427,10 @@ ActiveRecord::Schema.define(version: 20160721081015) do create_table "environments", force: :cascade do |t| t.integer "project_id" - t.string "name", null: false + t.string "name", null: false t.datetime "created_at" t.datetime "updated_at" + t.string "external_url" end add_index "environments", ["project_id", "name"], name: "index_environments_on_project_id_and_name", using: :btree @@ -607,9 +608,9 @@ ActiveRecord::Schema.define(version: 20160721081015) do add_index "merge_request_diffs", ["merge_request_id"], name: "index_merge_request_diffs_on_merge_request_id", unique: true, using: :btree create_table "merge_requests", force: :cascade do |t| - t.string "target_branch", null: false - t.string "source_branch", null: false - t.integer "source_project_id", null: false + t.string "target_branch", null: false + t.string "source_branch", null: false + t.integer "source_project_id", null: false t.integer "author_id" t.integer "assignee_id" t.string "title" @@ -618,20 +619,21 @@ ActiveRecord::Schema.define(version: 20160721081015) do t.integer "milestone_id" t.string "state" t.string "merge_status" - t.integer "target_project_id", null: false + t.integer "target_project_id", null: false t.integer "iid" t.text "description" - t.integer "position", default: 0 + t.integer "position", default: 0 t.datetime "locked_at" t.integer "updated_by_id" t.string "merge_error" t.text "merge_params" - t.boolean "merge_when_build_succeeds", default: false, null: false + t.boolean "merge_when_build_succeeds", default: false, null: false t.integer "merge_user_id" t.string "merge_commit_sha" t.datetime "deleted_at" t.string "in_progress_merge_commit_sha" end + add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree add_index "merge_requests", ["author_id"], name: "index_merge_requests_on_author_id", using: :btree add_index "merge_requests", ["created_at", "id"], name: "index_merge_requests_on_created_at_and_id", using: :btree @@ -850,7 +852,6 @@ ActiveRecord::Schema.define(version: 20160721081015) do end add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree - add_index "projects", ["builds_enabled"], name: "index_projects_on_builds_enabled", using: :btree add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree add_index "projects", ["created_at", "id"], name: "index_projects_on_created_at_and_id", using: :btree add_index "projects", ["creator_id"], name: "index_projects_on_creator_id", using: :btree @@ -866,13 +867,29 @@ ActiveRecord::Schema.define(version: 20160721081015) do add_index "projects", ["star_count"], name: "index_projects_on_star_count", using: :btree add_index "projects", ["visibility_level"], name: "index_projects_on_visibility_level", using: :btree + create_table "protected_branch_merge_access_levels", force: :cascade do |t| + t.integer "protected_branch_id", null: false + t.integer "access_level", default: 40, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "protected_branch_merge_access_levels", ["protected_branch_id"], name: "index_protected_branch_merge_access", using: :btree + + create_table "protected_branch_push_access_levels", force: :cascade do |t| + t.integer "protected_branch_id", null: false + t.integer "access_level", default: 40, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "protected_branch_push_access_levels", ["protected_branch_id"], name: "index_protected_branch_push_access", using: :btree + create_table "protected_branches", force: :cascade do |t| - t.integer "project_id", null: false - t.string "name", null: false + t.integer "project_id", null: false + t.string "name", null: false t.datetime "created_at" t.datetime "updated_at" - t.boolean "developers_can_push", default: false, null: false - t.boolean "developers_can_merge", default: false, null: false end add_index "protected_branches", ["project_id"], name: "index_protected_branches_on_project_id", using: :btree @@ -1135,5 +1152,7 @@ ActiveRecord::Schema.define(version: 20160721081015) do add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree add_foreign_key "personal_access_tokens", "users" + add_foreign_key "protected_branch_merge_access_levels", "protected_branches" + add_foreign_key "protected_branch_push_access_levels", "protected_branches" add_foreign_key "u2f_registrations", "users" end diff --git a/doc/README.md b/doc/README.md index b5b377822e6..fc51ea911b9 100644 --- a/doc/README.md +++ b/doc/README.md @@ -9,7 +9,7 @@ - [GitLab Basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab. - [Importing to GitLab](workflow/importing/README.md). - [Importing and exporting projects between instances](user/project/settings/import_export.md). -- [Markdown](markdown/markdown.md) GitLab's advanced formatting system. +- [Markdown](user/markdown.md) GitLab's advanced formatting system. - [Migrating from SVN](workflow/importing/migrating_from_svn.md) Convert a SVN repository to Git and GitLab. - [Permissions](user/permissions.md) Learn what each role in a project (external/guest/reporter/developer/master/owner) can do. - [Profile Settings](profile/README.md) @@ -54,7 +54,5 @@ ## Contributor documentation -- [Documentation styleguide](development/doc_styleguide.md) Use this styleguide if you are - contributing to documentation. -- [Development](development/README.md) Explains the architecture and the guidelines for shell commands. +- [Development](development/README.md) All styleguides and explanations how to contribute. - [Legal](legal/README.md) Contributor license agreements. diff --git a/doc/administration/build_artifacts.md b/doc/administration/build_artifacts.md new file mode 100644 index 00000000000..64353f7282b --- /dev/null +++ b/doc/administration/build_artifacts.md @@ -0,0 +1,90 @@ +# Build artifacts administration + +>**Notes:** +>- Introduced in GitLab 8.2 and GitLab Runner 0.7.0. +>- Starting from GitLab 8.4 and GitLab Runner 1.0, the artifacts archive format + changed to `ZIP`. +>- This is the administration documentation. For the user guide see + [user/project/builds/artifacts.md](../user/project/builds/artifacts.md). + +Artifacts is a list of files and directories which are attached to a build +after it completes successfully. This feature is enabled by default in all +GitLab installations. Keep reading if you want to know how to disable it. + +## Disabling build artifacts + +To disable artifacts site-wide, follow the steps below. + +--- + +**In Omnibus installations:** + +1. Edit `/etc/gitlab/gitlab.rb` and add the following line: + + ```ruby + gitlab_rails['artifacts_enabled'] = false + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +--- + +**In installations from source:** + +1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: + + ```yaml + artifacts: + enabled: false + ``` + +1. Save the file and [restart GitLab][] for the changes to take effect. + +## Storing build artifacts + +After a successful build, GitLab Runner uploads an archive containing the build +artifacts to GitLab. + +To change the location where the artifacts are stored, follow the steps below. + +--- + +**In Omnibus installations:** + +_The artifacts are stored by default in +`/var/opt/gitlab/gitlab-rails/shared/artifacts`._ + +1. To change the storage path for example to `/mnt/storage/artifacts`, edit + `/etc/gitlab/gitlab.rb` and add the following line: + + ```ruby + gitlab_rails['artifacts_path'] = "/mnt/storage/artifacts" + ``` + +1. Save the file and [reconfigure GitLab][] for the changes to take effect. + +--- + +**In installations from source:** + +_The artifacts are stored by default in +`/home/git/gitlab/shared/artifacts`._ + +1. To change the storage path for example to `/mnt/storage/artifacts`, edit + `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: + + ```yaml + artifacts: + enabled: true + path: /mnt/storage/artifacts + ``` + +1. Save the file and [restart GitLab][] for the changes to take effect. + +## Set the maximum file size of the artifacts + +Provided the artifacts are enabled, you can change the maximum file size of the +artifacts through the [Admin area settings](../user/admin_area/settings/continuous_integration#maximum-artifacts-size). + +[reconfigure gitlab]: restart_gitlab.md "How to restart GitLab" +[restart gitlab]: restart_gitlab.md "How to restart GitLab" diff --git a/doc/administration/container_registry.md b/doc/administration/container_registry.md index d5d43303454..b5db575477c 100644 --- a/doc/administration/container_registry.md +++ b/doc/administration/container_registry.md @@ -1,7 +1,6 @@ # GitLab Container Registry Administration -> **Note:** -This feature was [introduced][ce-4040] in GitLab 8.8. +> [Introduced][ce-4040] in GitLab 8.8. With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. diff --git a/doc/administration/custom_hooks.md b/doc/administration/custom_hooks.md index e3306c22d3f..0387d730489 100644 --- a/doc/administration/custom_hooks.md +++ b/doc/administration/custom_hooks.md @@ -44,8 +44,7 @@ as appropriate. ## Custom error messages ->**Note:** -This feature was [introduced][5073] in GitLab 8.10. +> [Introduced][5073] in GitLab 8.10. If the commit is declined or an error occurs during the Git hook check, the STDERR or STDOUT message of the hook will be present in GitLab's UI. diff --git a/doc/administration/housekeeping.md b/doc/administration/housekeeping.md index a5fa7d358a2..34b4f1faa94 100644 --- a/doc/administration/housekeeping.md +++ b/doc/administration/housekeeping.md @@ -1,6 +1,6 @@ # Housekeeping -_**Note:** This feature was [introduced][ce-2371] in GitLab 8.4_ +> [Introduced][ce-2371] in GitLab 8.4. --- diff --git a/doc/administration/raketasks/project_import_export.md b/doc/administration/raketasks/project_import_export.md index c212059b9d5..39b1883375e 100644 --- a/doc/administration/raketasks/project_import_export.md +++ b/doc/administration/raketasks/project_import_export.md @@ -1,13 +1,14 @@ # Project import/export >**Note:** - - This feature was [introduced][ce-3050] in GitLab 8.9 - - Importing will not be possible if the import instance version is lower - than that of the exporter. - - For existing installations, the project import option has to be enabled in - application settings (`/admin/application_settings`) under 'Import sources'. - - The exports are stored in a temporary [shared directory][tmp] and are deleted - every 24 hours by a specific worker. +> +> - [Introduced][ce-3050] in GitLab 8.9. +> - Importing will not be possible if the import instance version is lower +> than that of the exporter. +> - For existing installations, the project import option has to be enabled in +> application settings (`/admin/application_settings`) under 'Import sources'. +> - The exports are stored in a temporary [shared directory][tmp] and are deleted +> every 24 hours by a specific worker. The GitLab Import/Export version can be checked by using: diff --git a/doc/administration/repository_checks.md b/doc/administration/repository_checks.md index 4172b604cec..bc2b1f20ed3 100644 --- a/doc/administration/repository_checks.md +++ b/doc/administration/repository_checks.md @@ -1,8 +1,7 @@ # Repository checks ->**Note:** -This feature was [introduced][ce-3232] in GitLab 8.7. It is OFF by -default because it still causes too many false alarms. +> [Introduced][ce-3232] in GitLab 8.7. It is OFF by default because it still +causes too many false alarms. Git has a built-in mechanism, [git fsck][git-fsck], to verify the integrity of all data committed to a repository. GitLab administrators diff --git a/doc/administration/repository_storages.md b/doc/administration/repository_storages.md index a9e22e2bdaa..55b054fc1a4 100644 --- a/doc/administration/repository_storages.md +++ b/doc/administration/repository_storages.md @@ -15,13 +15,33 @@ storage load between several mount points. ## Configure GitLab >**Warning:** -- In order for backups to work correctly the storage path must **not** be a - mount point and the GitLab user should have correct permissions for the parent - directory of the path. +In order for [backups] to work correctly, the storage path must **not** be a +mount point and the GitLab user should have correct permissions for the parent +directory of the path. In Omnibus GitLab this is taken care of automatically, +but for source installations you should be extra careful. +> +The thing is that for compatibility reasons `gitlab.yml` has a different +structure than Omnibus. In `gitlab.yml` you indicate the path for the +repositories, for example `/home/git/repositories`, while in Omnibus you +indicate `git_data_dirs`, which for the example above would be `/home/git`. +Then, Omnibus will create a `repositories` directory under that path to use with +`gitlab.yml`. +> +This little detail matters because while restoring a backup, the current +contents of `/home/git/repositories` [are moved to][raketask] `/home/git/repositories.old`, +so if `/home/git/repositories` is the mount point, then `mv` would be moving +things between mount points, and bad things could happen. Ideally, +`/home/git` would be the mount point, so then things would be moving within the +same mount point. This is guaranteed with Omnibus installations (because they +don't specify the full repository path but the parent path), but not for source +installations. + +--- -Edit the configuration files and add the full paths of the alternative repository -storage paths. In the example below we added two more mountpoints that we named -`nfs` and `cephfs` respectively. +Now that you've read that big fat warning above, let's edit the configuration +files and add the full paths of the alternative repository storage paths. In +the example below, we add two more mountpoints that are named `nfs` and `cephfs` +respectively. **For installations from source** @@ -39,17 +59,12 @@ storage paths. In the example below we added two more mountpoints that we named 1. [Restart GitLab] for the changes to take effect. -The `gitlab_shell: repos_path` entry in `gitlab.yml` will be deprecated and -replaced by `repositories: storages` in the future, so if you are upgrading -from a version prior to 8.10, make sure to add the configuration as described -in the step above. After you make the changes and confirm they are working, -you can remove: - -```yaml -repos_path: /home/git/repositories -``` - -which is located under the `gitlab_shell` section. +>**Note:** +The [`gitlab_shell: repos_path` entry][repospath] in `gitlab.yml` will be +deprecated and replaced by `repositories: storages` in the future, so if you +are upgrading from a version prior to 8.10, make sure to add the configuration +as described in the step above. After you make the changes and confirm they are +working, you can remove the `repos_path` line. --- @@ -79,3 +94,6 @@ be stored via the **Application Settings** in the Admin area. [ce-4578]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4578 [restart gitlab]: restart_gitlab.md#installations-from-source [reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure +[backups]: ../raketasks/backup_restore.md +[raketask]: https://gitlab.com/gitlab-org/gitlab-ce/blob/033e5423a2594e08a7ebcd2379bd2331f4c39032/lib/backup/repository.rb#L54-56 +[repospath]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-9-stable/config/gitlab.yml.example#L457 diff --git a/doc/api/README.md b/doc/api/README.md index d1e6c54c521..21141d350cf 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -81,7 +81,7 @@ Read more about [GitLab as an OAuth2 client](oauth2.md). ### Personal Access Tokens -> **Note:** This feature was [introduced][ce-3749] in GitLab 8.8 +> [Introduced][ce-3749] in GitLab 8.8. You can create as many personal access tokens as you like from your GitLab profile (`/profile/personal_access_tokens`); perhaps one for each application diff --git a/doc/api/award_emoji.md b/doc/api/award_emoji.md index 796b3680a75..158fb189005 100644 --- a/doc/api/award_emoji.md +++ b/doc/api/award_emoji.md @@ -1,6 +1,6 @@ # Award Emoji - >**Note:** This feature was introduced in GitLab 8.9 +> [Introduced][ce-4575] in GitLab 8.9. An awarded emoji tells a thousand words, and can be awarded on issues, merge requests and notes/comments. Issues, merge requests and notes are further called @@ -365,3 +365,5 @@ Example Response: "awardable_type": "Note" } ``` + +[ce-4575]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4575 diff --git a/doc/api/commits.md b/doc/api/commits.md index 57c2e1d9b87..2960c2ae428 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -81,6 +81,11 @@ Example response: "parent_ids": [ "ae1d9fb46aa2b07ee9836d49862ec4e2c46fbbba" ], + "stats": { + "additions": 15, + "deletions": 10, + "total": 25 + }, "status": "running" } ``` diff --git a/doc/api/deploy_keys.md b/doc/api/deploy_keys.md index 4e620ccc81a..a288de5fc97 100644 --- a/doc/api/deploy_keys.md +++ b/doc/api/deploy_keys.md @@ -159,3 +159,51 @@ Example response: "id" : 13 } ``` + +## Enable a deploy key + +Enables a deploy key for a project so this can be used. Returns the enabled key, with a status code 201 when successful. + +```bash +curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/deploy_keys/13/enable +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the project | +| `key_id` | integer | yes | The ID of the deploy key | + +Example response: + +```json +{ + "key" : "ssh-rsa AAAA...", + "id" : 12, + "title" : "My deploy key", + "created_at" : "2015-08-29T12:44:31.550Z" +} +``` + +## Disable a deploy key + +Disable a deploy key for a project. Returns the disabled key. + +```bash +curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/deploy_keys/13/disable +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the project | +| `key_id` | integer | yes | The ID of the deploy key | + +Example response: + +```json +{ + "key" : "ssh-rsa AAAA...", + "id" : 12, + "title" : "My deploy key", + "created_at" : "2015-08-29T12:44:31.550Z" +} +``` diff --git a/doc/api/enviroments.md b/doc/api/enviroments.md new file mode 100644 index 00000000000..1e12ded448c --- /dev/null +++ b/doc/api/enviroments.md @@ -0,0 +1,117 @@ +# Environments + +## List environments + +Get all environments for a given project. + +``` +GET /projects/:id/environments +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | -------- | --------------------- | +| `id` | integer | yes | The ID of the project | + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/1/environments +``` + +Example response: + +```json +[ + { + "id": 1, + "name": "Env1", + "external_url": "https://env1.example.gitlab.com" + } +] +``` + +## Create a new environment + +Creates a new environment with the given name and external_url. + +It returns 201 if the environment was successfully created, 400 for wrong parameters. + +``` +POST /projects/:id/environment +``` + +| Attribute | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------- | +| `id` | integer | yes | The ID of the project | +| `name` | string | yes | The name of the environment | +| `external_url` | string | no | Place to link to for this environment | + +```bash +curl --data "name=deploy&external_url=https://deploy.example.gitlab.com" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environments" +``` + +Example response: + +```json +{ + "id": 1, + "name": "deploy", + "external_url": "https://deploy.example.gitlab.com" +} +``` + +## Edit an existing environment + +Updates an existing environment's name and/or external_url. + +It returns 200 if the environment was successfully updated. In case of an error, a status code 400 is returned. + +``` +PUT /projects/:id/environments/:environments_id +``` + +| Attribute | Type | Required | Description | +| --------------- | ------- | --------------------------------- | ------------------------------- | +| `id` | integer | yes | The ID of the project | +| `environment_id` | integer | yes | The ID of the environment | The ID of the environment | +| `name` | string | no | The new name of the environment | +| `external_url` | string | no | The new external_url | + +```bash +curl -X PUT --data "name=staging&external_url=https://staging.example.gitlab.com" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environment/1" +``` + +Example response: + +```json +{ + "id": 1, + "name": "staging", + "external_url": "https://staging.example.gitlab.com" +} +``` + +## Delete an environment + +It returns 200 if the environment was successfully deleted, and 404 if the environment does not exist. + +``` +DELETE /projects/:id/environments/:environment_id +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | -------- | --------------------- | +| `id` | integer | yes | The ID of the project | +| `environment_id` | integer | yes | The ID of the environment | + +```bash +curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/environment/1" +``` + +Example response: + +```json +{ + "id": 1, + "name": "deploy", + "external_url": "https://deploy.example.gitlab.com" +} +``` diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index a8c3b068d22..e00882e6d5d 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -276,6 +276,7 @@ Parameters: ```json { "id": 1, + "iid": 1, "target_branch": "master", "source_branch": "test1", "project_id": 3, @@ -350,6 +351,7 @@ Parameters: ```json { "id": 1, + "iid": 1, "target_branch": "master", "project_id": 3, "title": "test1", @@ -449,6 +451,7 @@ Parameters: ```json { "id": 1, + "iid": 1, "target_branch": "master", "source_branch": "test1", "project_id": 3, @@ -517,6 +520,7 @@ Parameters: ```json { "id": 1, + "iid": 1, "target_branch": "master", "source_branch": "test1", "project_id": 3, diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md index 31902e145f6..7ce89adc98b 100644 --- a/doc/api/oauth2.md +++ b/doc/api/oauth2.md @@ -35,7 +35,7 @@ Where REDIRECT_URI is the URL in your app where users will be sent after authori To request the access token, you should use the returned code and exchange it for an access token. To do that you can use any HTTP client. In this case, I used rest-client: ``` -parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=AUTHORIZATION_CODE&redirect_uri=REDIRECT_URI' +parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI' RestClient.post 'http://localhost:3000/oauth/token', parameters # The response will be diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index 623063f357b..1b8ee88b4ed 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -12,6 +12,10 @@ Allows you to receive information about file in repository like name, size, cont GET /projects/:id/repository/files ``` +```bash +curl -X GET -H 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/models/key.rb&ref=master' +``` + Example response: ```json @@ -39,6 +43,10 @@ Parameters: POST /projects/:id/repository/files ``` +```bash +curl -X POST -H 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/project.rb&branch_name=master&content=some%20content&commit_message=create%20a%20new%20file' +``` + Example response: ```json @@ -62,6 +70,10 @@ Parameters: PUT /projects/:id/repository/files ``` +```bash +curl -X PUT -H 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/project.rb&branch_name=master&content=some%20other%20content&commit_message=update%20file' +``` + Example response: ```json @@ -94,6 +106,10 @@ Currently gitlab-shell has a boolean return code, preventing GitLab from specify DELETE /projects/:id/repository/files ``` +```bash +curl -X PUT -H 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v3/projects/13083/repository/files?file_path=app/project.rb&branch_name=master&commit_message=delete%20file' +``` + Example response: ```json diff --git a/doc/api/todos.md b/doc/api/todos.md index 937c71de386..c9e1e83e28a 100644 --- a/doc/api/todos.md +++ b/doc/api/todos.md @@ -1,6 +1,6 @@ # Todos -**Note:** This feature was [introduced][ce-3188] in GitLab 8.10 +> [Introduced][ce-3188] in GitLab 8.10. ## Get a list of todos diff --git a/doc/ci/README.md b/doc/ci/README.md index 0833027f91d..10ce4ac8940 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -14,7 +14,7 @@ - [Use variables in your `.gitlab-ci.yml`](variables/README.md) - [Use SSH keys in your build environment](ssh_keys/README.md) - [Trigger builds through the API](triggers/README.md) -- [Build artifacts](build_artifacts/README.md) +- [Build artifacts](../user/project/builds/artifacts.md) - [User permissions](../user/permissions.md#gitlab-ci) - [API](../api/ci/README.md) - [CI services (linked docker containers)](services/README.md) diff --git a/doc/ci/build_artifacts/README.md b/doc/ci/build_artifacts/README.md index 9553bb11e9d..05605f10fb4 100644 --- a/doc/ci/build_artifacts/README.md +++ b/doc/ci/build_artifacts/README.md @@ -1,175 +1,4 @@ -# Introduction to build artifacts +This document was moved to: -Artifacts is a list of files and directories which are attached to a build -after it completes successfully. This feature is enabled by default in all GitLab installations. - -_If you are searching for ways to use artifacts, jump to -[Defining artifacts in `.gitlab-ci.yml`](#defining-artifacts-in-gitlab-ciyml)._ - -Since GitLab 8.2 and [GitLab Runner] 0.7.0, build artifacts that are created by -GitLab Runner are uploaded to GitLab and are downloadable as a single archive -(`tar.gz`) using the GitLab UI. - -Starting from GitLab 8.4 and GitLab Runner 1.0, the artifacts archive format -changed to `ZIP`, and it is now possible to browse its contents, with the added -ability of downloading the files separately. - -**Note:** -The artifacts browser will be available only for new artifacts that are sent -to GitLab using GitLab Runner version 1.0 and up. It will not be possible to -browse old artifacts already uploaded to GitLab. - -## Disabling build artifacts - -To disable artifacts site-wide, follow the steps below. - ---- - -**In Omnibus installations:** - -1. Edit `/etc/gitlab/gitlab.rb` and add the following line: - - ```ruby - gitlab_rails['artifacts_enabled'] = false - ``` - -1. Save the file and [reconfigure GitLab][] for the changes to take effect. - ---- - -**In installations from source:** - -1. Edit `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: - - ```yaml - artifacts: - enabled: false - ``` - -1. Save the file and [restart GitLab][] for the changes to take effect. - -## Defining artifacts in `.gitlab-ci.yml` - -A simple example of using the artifacts definition in `.gitlab-ci.yml` would be -the following: - -```yaml -pdf: - script: xelatex mycv.tex - artifacts: - paths: - - mycv.pdf -``` - -A job named `pdf` calls the `xelatex` command in order to build a pdf file from -the latex source file `mycv.tex`. We then define the `artifacts` paths which in -turn are defined with the `paths` keyword. All paths to files and directories -are relative to the repository that was cloned during the build. - -For more examples on artifacts, follow the -[separate artifacts yaml documentation](../yaml/README.md#artifacts). - -## Storing build artifacts - -After a successful build, GitLab Runner uploads an archive containing the build -artifacts to GitLab. - -To change the location where the artifacts are stored, follow the steps below. - ---- - -**In Omnibus installations:** - -_The artifacts are stored by default in -`/var/opt/gitlab/gitlab-rails/shared/artifacts`._ - -1. To change the storage path for example to `/mnt/storage/artifacts`, edit - `/etc/gitlab/gitlab.rb` and add the following line: - - ```ruby - gitlab_rails['artifacts_path'] = "/mnt/storage/artifacts" - ``` - -1. Save the file and [reconfigure GitLab][] for the changes to take effect. - ---- - -**In installations from source:** - -_The artifacts are stored by default in -`/home/git/gitlab/shared/artifacts`._ - -1. To change the storage path for example to `/mnt/storage/artifacts`, edit - `/home/git/gitlab/config/gitlab.yml` and add or amend the following lines: - - ```yaml - artifacts: - enabled: true - path: /mnt/storage/artifacts - ``` - -1. Save the file and [restart GitLab][] for the changes to take effect. - -## Browsing build artifacts - -When GitLab receives an artifacts archive, an archive metadata file is also -generated. This metadata file describes all the entries that are located in the -artifacts archive itself. The metadata file is in a binary format, with -additional GZIP compression. - -GitLab does not extract the artifacts archive in order to save space, memory -and disk I/O. It instead inspects the metadata file which contains all the -relevant information. This is especially important when there is a lot of -artifacts, or an archive is a very large file. - ---- - -After a successful build, if you visit the build's specific page, you can see -that there are two buttons. - -One is for downloading the artifacts archive and the other for browsing its -contents. - -![Build artifacts browser button](img/build_artifacts_browser_button.png) - ---- - -The archive browser shows the name and the actual file size of each file in the -archive. If your artifacts contained directories, then you are also able to -browse inside them. - -Below you can see an image of three different file formats, as well as two -directories. - -![Build artifacts browser](img/build_artifacts_browser.png) - ---- - -## Downloading build artifacts - -If you need to download the whole archive, there are buttons in various places -inside GitLab that make that possible. - -1. While on the builds page, you can see the download icon for each build's - artifacts archive in the right corner - -1. While inside a specific build, you are presented with a download button - along with the one that browses the archive - -1. And finally, when browsing an archive you can see the download button at - the top right corner - ---- - -Note that GitLab does not extract the entire artifacts archive to send just a -single file to the user. - -When clicking on a specific file, [GitLab Workhorse] extracts it from the -archive and the download begins. - -This implementation saves space, memory and disk I/O. - -[gitlab runner]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner "GitLab Runner repository" -[reconfigure gitlab]: ../../administration/restart_gitlab.md "How to restart GitLab documentation" -[restart gitlab]: ../../administration/restart_gitlab.md "How to restart GitLab documentation" -[gitlab workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse "GitLab Workhorse repository" +- [user/project/builds/artifacts.md](../../user/project/builds/artifacts.md) - user guide +- [administration/build_artifacts.md](../../administration/build_artifacts.md) - administrator guide diff --git a/doc/ci/build_artifacts/img/build_artifacts_browser.png b/doc/ci/build_artifacts/img/build_artifacts_browser.png Binary files differdeleted file mode 100644 index 59cf2b8746b..00000000000 --- a/doc/ci/build_artifacts/img/build_artifacts_browser.png +++ /dev/null diff --git a/doc/ci/build_artifacts/img/build_artifacts_browser_button.png b/doc/ci/build_artifacts/img/build_artifacts_browser_button.png Binary files differdeleted file mode 100644 index 7801c2e6fa6..00000000000 --- a/doc/ci/build_artifacts/img/build_artifacts_browser_button.png +++ /dev/null diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 7f83f846454..0f64137a8a9 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -38,7 +38,7 @@ GitLab Runner then executes build scripts as the `gitlab-runner` user. $ sudo gitlab-ci-multi-runner register -n \ --url https://gitlab.com/ci \ --registration-token REGISTRATION_TOKEN \ - --executor shell + --executor shell \ --description "My Runner" ``` diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 7fa1a478f34..6a3c416d995 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -233,7 +233,7 @@ Awesome! You started using CI in GitLab! Visit the [examples README][examples] to see a list of examples using GitLab CI with various languages. -[runner-install]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/tree/master#installation +[runner-install]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/tree/master#install-gitlab-runner [blog-ci]: https://about.gitlab.com/2015/05/06/why-were-replacing-gitlab-ci-jobs-with-gitlab-ci-dot-yml/ [examples]: ../examples/README.md [ci]: https://about.gitlab.com/gitlab-ci/ diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md index 5c316510d0e..57a12526363 100644 --- a/doc/ci/triggers/README.md +++ b/doc/ci/triggers/README.md @@ -1,6 +1,6 @@ # Triggering Builds through the API -_**Note:** This feature was [introduced][ci-229] in GitLab CE 7.14_ +> [Introduced][ci-229] in GitLab CE 7.14. Triggers can be used to force a rebuild of a specific branch, tag or commit, with an API call. diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ea3fff1596e..01d71088543 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -379,6 +379,8 @@ job: - bundle exec rspec ``` +Sometimes, `script` commands will need to be wrapped in single or double quotes. For example, commands that contain a colon (`:`) need to be wrapped in quotes so that the YAML parser knows to interpret the whole thing as a string rather than a "key: value" pair. Be careful when using special characters (`:`, `{`, `}`, `[`, `]`, `,`, `&`, `*`, `#`, `?`, `|`, `-`, `<`, `>`, `=`, `!`, `%`, `@`, `` ` ``). + ### stage `stage` allows to group build into different stages. Builds of the same `stage` diff --git a/doc/container_registry/README.md b/doc/container_registry/README.md index 55077197ff9..047a0b08406 100644 --- a/doc/container_registry/README.md +++ b/doc/container_registry/README.md @@ -1,8 +1,7 @@ # GitLab Container Registry -> **Note:** -This feature was [introduced][ce-4040] in GitLab 8.8. Docker Registry manifest -v1 support was added in GitLab 8.9 to support Docker versions earlier than 1.10. +> [Introduced][ce-4040] in GitLab 8.8. Docker Registry manifest +`v1` support was added in GitLab 8.9 to support Docker versions earlier than 1.10. > **Note:** This document is about the user guide. To learn how to enable GitLab Container @@ -90,6 +89,10 @@ your `.gitlab-ci.yml`, you have to follow the [Using a private Docker Registry][private-docker] documentation. This workflow will be simplified in the future. +## Troubleshooting + +See [the GitLab Docker registry troubleshooting guide](troubleshooting.md). + [ce-4040]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4040 [docker-docs]: https://docs.docker.com/engine/userguide/intro/ [private-docker]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/blob/master/docs/configuration/advanced-configuration.md#using-a-private-docker-registry diff --git a/doc/container_registry/img/mitmproxy-docker.png b/doc/container_registry/img/mitmproxy-docker.png Binary files differnew file mode 100644 index 00000000000..4e3e37b413d --- /dev/null +++ b/doc/container_registry/img/mitmproxy-docker.png diff --git a/doc/container_registry/troubleshooting.md b/doc/container_registry/troubleshooting.md new file mode 100644 index 00000000000..14c4a7d9a63 --- /dev/null +++ b/doc/container_registry/troubleshooting.md @@ -0,0 +1,141 @@ +# Troubleshooting the GitLab Container Registry + +## Basic Troubleshooting + +1. Check to make sure that the system clock on your Docker client and GitLab server have + been synchronized (e.g. via NTP). + +2. If you are using an S3-backed Registry, double check that the IAM + permissions and the S3 credentials (including region) are correct. See [the + sample IAM policy](https://docs.docker.com/registry/storage-drivers/s3/) + for more details. + +3. Check the Registry logs (e.g. `/var/log/gitlab/registry/current`) and the GitLab production logs + for errors (e.g. `/var/log/gitlab/gitlab-rails/production.log`). You may be able to find clues + there. + +## Advanced Troubleshooting + +>**NOTE:** The following section is only recommended for experts. + +Sometimes it's not obvious what is wrong, and you may need to dive deeper into +the communication between the Docker client and the Registry to find out +what's wrong. We will use a concrete example in the past to illustrate how to +diagnose a problem with the S3 setup. + +### Unexpected 403 error during push + +A user attempted to enable an S3-backed Registry. The `docker login` step went +fine. However, when pushing an image, the output showed: + +``` +The push refers to a repository [s3-testing.myregistry.com:4567/root/docker-test] +dc5e59c14160: Pushing [==================================================>] 14.85 kB +03c20c1a019a: Pushing [==================================================>] 2.048 kB +a08f14ef632e: Pushing [==================================================>] 2.048 kB +228950524c88: Pushing 2.048 kB +6a8ecde4cc03: Pushing [==> ] 9.901 MB/205.7 MB +5f70bf18a086: Pushing 1.024 kB +737f40e80b7f: Waiting +82b57dbc5385: Waiting +19429b698a22: Waiting +9436069b92a3: Waiting +error parsing HTTP 403 response body: unexpected end of JSON input: "" +``` + +This error is ambiguous, as it's not clear whether the 403 is coming from the +GitLab Rails application, the Docker Registry, or something else. In this +case, since we know that since the login succeeded, we probably need to look +at the communication between the client and the Registry. + +The REST API between the Docker client and Registry is [described +here](https://docs.docker.com/registry/spec/api/). Normally, one would just +use Wireshark or tcpdump to capture the traffic and see where things went +wrong. However, since all communication between Docker clients and servers +are done over HTTPS, it's a bit difficult to decrypt the traffic quickly even +if you know the private key. What can we do instead? + +One way would be to disable HTTPS by setting up an [insecure +Registry](https://docs.docker.com/registry/insecure/). This could introduce a +security hole and is only recommended for local testing. If you have a +production system and can't or don't want to do this, there is another way: +use mitmproxy, which stands for Man-in-the-Middle Proxy. + +### mitmproxy + +[mitmproxy](https://mitmproxy.org/) allows you to place a proxy between your +client and server to inspect all traffic. One wrinkle is that your system +needs to trust the mitmproxy SSL certificates for this to work. + +The following installation instructions assume you are running Ubuntu: + +1. Install mitmproxy (see http://docs.mitmproxy.org/en/stable/install.html) +1. Run `mitmproxy --port 9000` to generate its certificates. + Enter <kbd>CTRL</kbd>-<kbd>C</kbd> to quit. +1. Install the certificate from `~/.mitmproxy` to your system: + + ```sh + sudo cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy-ca-cert.crt + sudo update-ca-certificates + ``` + +If successful, the output should indicate that a certificate was added: + +```sh +Updating certificates in /etc/ssl/certs... 1 added, 0 removed; done. +Running hooks in /etc/ca-certificates/update.d....done. +``` + +To verify that the certificates are properly installed, run: + +```sh +mitmproxy --port 9000 +``` + +This will run mitmproxy on port `9000`. In another window, run: + +```sh +curl --proxy http://localhost:9000 https://httpbin.org/status/200 +``` + +If everything is setup correctly, you will see information on the mitmproxy window and +no errors from the curl commands. + +### Running the Docker daemon with a proxy + +For Docker to connect through a proxy, you must start the Docker daemon with the +proper environment variables. The easiest way is to shutdown Docker (e.g. `sudo initctl stop docker`) +and then run Docker by hand. As root, run: + +```sh +export HTTP_PROXY="http://localhost:9000" +export HTTPS_PROXY="https://localhost:9000" +docker daemon --debug +``` + +This will launch the Docker daemon and proxy all connections through mitmproxy. + +### Running the Docker client + +Now that we have mitmproxy and Docker running, we can attempt to login and push +a container image. You may need to run as root to do this. For example: + +```sh +docker login s3-testing.myregistry.com:4567 +docker push s3-testing.myregistry.com:4567/root/docker-test +``` + +In the example above, we see the following trace on the mitmproxy window: + +![mitmproxy output from Docker](img/mitmproxy-docker.png) + +The above image shows: + +* The initial PUT requests went through fine with a 201 status code. +* The 201 redirected the client to the S3 bucket. +* The HEAD request to the AWS bucket reported a 403 Unauthorized. + +What does this mean? This strongly suggests that the S3 user does not have the right +[permissions to perform a HEAD request](http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html). +The solution: check the [IAM permissions again](https://docs.docker.com/registry/storage-drivers/s3/). +Once the right permissions were set, the error will go away. diff --git a/doc/development/README.md b/doc/development/README.md index c5d5af43864..7b5f7ff8ad3 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -1,18 +1,38 @@ # Development +## Outside of docs + +- [CONTRIBUTING.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md) main contributing guide +- [PROCESS.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/PROCESS.md) contributing process +- [GitLab Development Kit (GDK)](https://gitlab.com/gitlab-org/gitlab-development-kit) to install a development version + +## Styleguides + +- [Documentation styleguide](development/doc_styleguide.md) Use this styleguide if you are + contributing to documentation. +- [Migration Style Guide](migration_style_guide.md) for creating safe migrations +- [Testing standards and style guidelines](testing.md) +- [UI guide](ui_guide.md) for building GitLab with existing css styles and elements +- [SQL guidelines](sql.md) for SQL guidelines + + +## Process + +- [Code review guidelines](code_review.md) for reviewing code and having code reviewed. + +## Backend howtos + - [Architecture](architecture.md) of GitLab - [CI setup](ci_setup.md) for testing GitLab -- [Code review guidelines](code_review.md) for reviewing code and having code - reviewed. - [Gotchas](gotchas.md) to avoid - [How to dump production data to staging](db_dump.md) - [Instrumentation](instrumentation.md) -- [Licensing](licensing.md) for ensuring license compliance -- [Migration Style Guide](migration_style_guide.md) for creating safe migrations - [Performance guidelines](performance.md) - [Rake tasks](rake_tasks.md) for development - [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) -- [SQL guidelines](sql.md) for SQL guidelines -- [Testing standards and style guidelines](testing.md) -- [UI guide](ui_guide.md) for building GitLab with existing css styles and elements +- [What requires downtime?](what_requires_downtime.md) + +## Compliance + +- [Licensing](licensing.md) for ensuring license compliance diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 6ee7b3cfeeb..994005f929f 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -3,12 +3,64 @@ This styleguide recommends best practices to improve documentation and to keep it organized and easy to find. -## Naming +## Location and naming of documents -- When creating a new document and it has more than one word in its name, - make sure to use underscores instead of spaces or dashes (`-`). For example, - a proper naming would be `import_projects_from_github.md`. The same rule - applies to images. +>**Note:** +These guidelines derive from the discussion taken place in issue [#3349](ce-3349). + +The documentation hierarchy can be vastly improved by providing a better layout +and organization of directories. + +Having a structured document layout, we will be able to have meaningful URLs +like `docs.gitlab.com/user/project/merge_requests.html`. With this pattern, +you can immediately tell that you are navigating a user related documentation +and is about the project and its merge requests. + +The table below shows what kind of documentation goes where. + +| Directory | What belongs here | +| --------- | -------------- | +| `doc/user/` | User related documentation. Anything that can be done within the GitLab UI goes here including `/admin`. | +| `doc/administration/` | Documentation that requires the user to have access to the server where GitLab is installed. The admin settings that can be accessed via GitLab's interface go under `doc/user/admin_area/`. | +| `doc/api/` | API related documentation. | +| `doc/development/` | Documentation related to the development of GitLab. Any styleguides should go here. | +| `doc/legal/` | Legal documents about contributing to GitLab. | +| `doc/install/`| Probably the most visited directory, since `installation.md` is there. Ideally this should go under `doc/administration/`, but it's best to leave it as-is in order to avoid confusion (still debated though). | +| `doc/update/` | Same with `doc/install/`. Should be under `administration/`, but this is a well known location, better leave as-is, at least for now. | + +--- + +**General rules:** + +1. The correct naming and location of a new document, is a combination + of the relative URL of the document in question and the GitLab Map design + that is used for UX purposes ([source][graffle], [image][gitlab-map]). +1. When creating a new document and it has more than one word in its name, + make sure to use underscores instead of spaces or dashes (`-`). For example, + a proper naming would be `import_projects_from_github.md`. The same rule + applies to images. +1. There are four main directories, `user`, `administration`, `api` and `development`. +1. The `doc/user/` directory has five main subdirectories: `project/`, `group/`, + `profile/`, `dashboard/` and `admin_area/`. + 1. `doc/user/project/` should contain all project related documentation. + 1. `doc/user/group/` should contain all group related documentation. + 1. `doc/user/profile/` should contain all profile related documentation. + Every page you would navigate under `/profile` should have its own document, + i.e. `account.md`, `applications.md`, `emails.md`, etc. + 1. `doc/user/dashboard/` should contain all dashboard related documentation. + 1. `doc/user/admin_area/` should contain all admin related documentation + describing what can be achieved by accessing GitLab's admin interface + (_not to be confused with `doc/administration` where server access is + required_). + 1. Every category under `/admin/application_settings` should have its + own document located at `doc/user/admin_area/settings/`. For example, + the **Visibility and Access Controls** category should have a document + located at `doc/user/admin_area/settings/visibility_and_access_controls.md`. + +--- + +If you are unsure where a document should live, you can ping `@axil` in your +merge request. ## Text @@ -103,15 +155,15 @@ Inside the document: - Every piece of documentation that comes with a new feature should declare the GitLab version that feature got introduced. Right below the heading add a - note: `>**Note:** This feature was introduced in GitLab 8.3` + note: `> Introduced in GitLab 8.3.`. - If possible every feature should have a link to the MR that introduced it. The above note would be then transformed to: - `>**Note:** This feature was [introduced][ce-1242] in GitLab 8.3`, where + `> [Introduced][ce-1242] in GitLab 8.3.`, where the [link identifier](#links) is named after the repository (CE) and the MR - number + number. - If the feature is only in GitLab EE, don't forget to mention it, like: - `>**Note:** This feature was introduced in GitLab EE 8.3`. Otherwise, leave - this mention out + `> Introduced in GitLab EE 8.3.`. Otherwise, leave + this mention out. ## References @@ -244,6 +296,12 @@ In this case: Here is a list of must-have items. Use them in the exact order that appears on this document. Further explanation is given below. +- Every method must be described using [Grape's DSL](https://github.com/ruby-grape/grape/tree/v0.13.0#describing-methods) + (see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/api/environments.rb + for a good example): + - `desc` for the method summary (you can pass it a block for additional details) + - `params` for the method params (this acts as description **and** validation + of the params) - Every method must have the REST API request. For example: ``` @@ -366,3 +424,6 @@ curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -d "domain_whitelist[]=*.ex [single spaces]: http://www.slate.com/articles/technology/technology/2011/01/space_invaders.html [gfm]: http://docs.gitlab.com/ce/markdown/markdown.html#newlines "GitLab flavored markdown documentation" [doc-restart]: ../administration/restart_gitlab.md "GitLab restart documentation" +[ce-3349]: https://gitlab.com/gitlab-org/gitlab-ce/issues/3349 "Documentation restructure" +[graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/d8d39f4a87b90fb9ae89ca12dc565347b4900d5e/production/resources/gitlab-map.graffle +[gitlab-map]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md index 9d7fe7440d2..159d5ce286d 100644 --- a/doc/development/gotchas.md +++ b/doc/development/gotchas.md @@ -41,10 +41,10 @@ Rubocop](https://gitlab.com/gitlab-org/gitlab-ce/blob/8-4-stable/.rubocop.yml#L9 [Exception]: http://stackoverflow.com/q/10048173/223897 -## Don't use inline CoffeeScript in views +## Don't use inline CoffeeScript/JavaScript in views Using the inline `:coffee` or `:coffeescript` Haml filters comes with a -performance overhead. +performance overhead. Using inline JavaScript is not a good way to structure your code and should be avoided. _**Note:** We've [removed these two filters](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/initializers/hamlit.rb) in an initializer._ @@ -52,6 +52,7 @@ in an initializer._ ### Further reading - Pull Request: [Replace CoffeeScript block into JavaScript in Views](https://git.io/vztMu) +- Stack Overflow: [Why you should not write inline JavaScript](http://programmers.stackexchange.com/questions/86589/why-should-i-avoid-inline-scripting) - Stack Overflow: [Performance implications of using :coffescript filter inside HAML templates?](http://stackoverflow.com/a/17571242/223897) ## ID-based CSS selectors need to be a bit more specific diff --git a/doc/development/newlines_styleguide.md b/doc/development/newlines_styleguide.md new file mode 100644 index 00000000000..e03adcaadea --- /dev/null +++ b/doc/development/newlines_styleguide.md @@ -0,0 +1,102 @@ +# Newlines styleguide + +This style guide recommends best practices for newlines in Ruby code. + +## Rule: separate code with newlines only when it makes sense from logic perspectice + +```ruby +# bad +def method + issue = Issue.new + + issue.save + + render json: issue +end +``` + +```ruby +# good +def method + issue = Issue.new + issue.save + + render json: issue +end +``` + +## Rule: separate code and block with newlines + +### Newline before block + +```ruby +# bad +def method + issue = Issue.new + if issue.save + render json: issue + end +end +``` + +```ruby +# good +def method + issue = Issue.new + + if issue.save + render json: issue + end +end +``` + +## Newline after block + +```ruby +# bad +def method + if issue.save + issue.send_email + end + render json: issue +end +``` + +```ruby +# good +def method + if issue.save + issue.send_email + end + + render json: issue +end +``` + +### Exception: no need for newline when code block starts or ends right inside another code block + +```ruby +# bad +def method + + if issue + + if issue.valid? + issue.save + end + + end + +end +``` + +```ruby +# good +def method + if issue + if issue.valid? + issue.save + end + end +end +``` diff --git a/doc/development/rake_tasks.md b/doc/development/rake_tasks.md index 8852dbcb19e..a7175f3f87e 100644 --- a/doc/development/rake_tasks.md +++ b/doc/development/rake_tasks.md @@ -14,11 +14,33 @@ Note: `db:setup` calls `db:seed` but this does nothing. ## Run tests -This runs all test suites present in GitLab. +In order to run the test you can use the following commands: +- `rake spinach` to run the spinach suite +- `rake spec` to run the rspec suite +- `rake teaspoon` to run the teaspoon test suite +- `rake gitlab:test` to run all the tests -``` -bundle exec rake test -``` +Note: Both `rake spinach` and `rake spec` takes significant time to pass. +Instead of running full test suite locally you can save a lot of time by running +a single test or directory related to your changes. After you submit merge request +CI will run full test suite for you. Green CI status in the merge request means +full test suite is passed. + +Note: You can't run `rspec .` since this will try to run all the `_spec.rb` +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 + +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 + +If you want to use [Spring](https://github.com/rails/spring) set +`ENABLE_SPRING=1` in your environment. ## Generate searchable docs for source code diff --git a/doc/development/what_requires_downtime.md b/doc/development/what_requires_downtime.md new file mode 100644 index 00000000000..abd693cf72d --- /dev/null +++ b/doc/development/what_requires_downtime.md @@ -0,0 +1,153 @@ +# What requires downtime? + +When working with a database certain operations can be performed without taking +GitLab offline, others do require a downtime period. This guide describes +various operations and their impact. + +## Adding Columns + +On PostgreSQL you can safely add a new column to an existing table as long as it +does **not** have a default value. For example, this query would not require +downtime: + +```sql +ALTER TABLE projects ADD COLUMN random_value int; +``` + +Add a column _with_ a default however does require downtime. For example, +consider this query: + +```sql +ALTER TABLE projects ADD COLUMN random_value int DEFAULT 42; +``` + +This requires updating every single row in the `projects` table so that +`random_value` is set to `42` by default. This requires updating all rows and +indexes in a table. This in turn acquires enough locks on the table for it to +effectively block any other queries. + +As of MySQL 5.6 adding a column to a table is still quite an expensive +operation, even when using `ALGORITHM=INPLACE` and `LOCK=NONE`. This means +downtime _may_ be required when modifying large tables as otherwise the +operation could potentially take hours to complete. + +## Dropping Columns + +On PostgreSQL you can safely remove an existing column without the need for +downtime. When you drop a column in PostgreSQL it's not immediately removed, +instead it is simply disabled. The data is removed on the next vacuum run. + +On MySQL this operation requires downtime. + +While database wise dropping a column may be fine on PostgreSQL this operation +still requires downtime because the application code may still be using the +column that was removed. For example, consider the following migration: + +```ruby +class MyMigration < ActiveRecord::Migration + def change + remove_column :projects, :dummy + end +end +``` + +Now imagine that the GitLab instance is running and actively uses the `dummy` +column. If we were to run the migration this would result in the GitLab instance +producing errors whenever it tries to use the `dummy` column. + +As a result of the above downtime _is_ required when removing a column, even +when using PostgreSQL. + +## Changing Column Constraints + +Generally changing column constraints requires checking all rows in the table to +see if they meet the new constraint, unless a constraint is _removed_. For +example, changing a column that previously allowed NULL values to not allow NULL +values requires the database to verify all existing rows. + +The specific behaviour varies a bit between databases but in general the safest +approach is to assume changing constraints requires downtime. + +## Changing Column Types + +This operation requires downtime. + +## Adding Indexes + +Adding indexes is an expensive process that blocks INSERT and UPDATE queries for +the duration. When using PostgreSQL one can work arounds this by using the +`CONCURRENTLY` option: + +```sql +CREATE INDEX CONCURRENTLY index_name ON projects (column_name); +``` + +Migrations can take advantage of this by using the method +`add_concurrent_index`. For example: + +```ruby +class MyMigration < ActiveRecord::Migration + def change + add_concurrent_index :projects, :column_name + end +end +``` + +When running this on PostgreSQL the `CONCURRENTLY` option mentioned above is +used. On MySQL this method produces a regular `CREATE INDEX` query. + +MySQL doesn't really have a workaround for this. Supposedly it _can_ create +indexes without the need for downtime but only for variable width columns. The +details on this are a bit sketchy. Since it's better to be safe than sorry one +should assume that adding indexes requires downtime on MySQL. + +## Dropping Indexes + +Dropping an index does not require downtime on both PostgreSQL and MySQL. + +## Adding Tables + +This operation is safe as there's no code using the table just yet. + +## Dropping Tables + +This operation requires downtime as application code may still be using the +table. + +## Adding Foreign Keys + +Adding foreign keys acquires an exclusive lock on both the source and target +tables in PostgreSQL. This requires downtime as otherwise the entire application +grinds to a halt for the duration of the operation. + +On MySQL this operation also requires downtime _unless_ foreign key checks are +disabled. Because this means checks aren't enforced this is not ideal, as such +one should assume MySQL also requires downtime. + +## Removing Foreign Keys + +This operation should not require downtime on both PostgreSQL and MySQL. + +## Updating Data + +Updating data should generally be safe. The exception to this is data that's +being migrated from one version to another while the application still produces +data in the old version. + +For example, imagine the application writes the string `'dog'` to a column but +it really is meant to write `'cat'` instead. One might think that the following +migration is all that is needed to solve this problem: + +```ruby +class MyMigration < ActiveRecord::Migration + def up + execute("UPDATE some_table SET column = 'cat' WHERE column = 'dog';") + end +end +``` + +Unfortunately this is not enough. Because the application is still running and +using the old value this may result in the table still containing rows where +`column` is set to `dog`, even after the migration finished. + +In these cases downtime _is_ required, even for rarely updated tables. diff --git a/doc/gitlab-basics/start-using-git.md b/doc/gitlab-basics/start-using-git.md index 89ce8bcc3e8..b61f436c1a4 100644 --- a/doc/gitlab-basics/start-using-git.md +++ b/doc/gitlab-basics/start-using-git.md @@ -120,3 +120,11 @@ You need to be in the created branch. git checkout NAME-OF-BRANCH git merge master ``` + +### Merge master branch with created branch +You need to be in the master branch. +``` +git checkout master +git merge NAME-OF-BRANCH +``` + diff --git a/doc/install/installation.md b/doc/install/installation.md index 9bc0dbb5e2a..af8e31a705b 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -269,9 +269,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-10-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-11-stable gitlab -**Note:** You can change `8-10-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `8-11-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/integration/akismet.md b/doc/integration/akismet.md index 5cc09bd536d..c222d21612f 100644 --- a/doc/integration/akismet.md +++ b/doc/integration/akismet.md @@ -1,9 +1,14 @@ # Akismet +> *Note:* Before 8.11 only issues submitted via the API and for non-project +members were submitted to Akismet. + GitLab leverages [Akismet](http://akismet.com) to protect against spam. Currently -GitLab uses Akismet to prevent users who are not members of a project from -creating spam via the GitLab API. Detected spam will be rejected, and -an entry in the "Spam Log" section in the Admin page will be created. +GitLab uses Akismet to prevent the creation of spam issues on public projects. Issues +created via the WebUI or the API can be submitted to Akismet for review. + +Detected spam will be rejected, and an entry in the "Spam Log" section in the +Admin page will be created. Privacy note: GitLab submits the user's IP and user agent to Akismet. Note that adding a user to a project will disable the Akismet check and prevent this diff --git a/doc/markdown/markdown.md b/doc/markdown/markdown.md index 2bbe4b2a36e..4ac81ab3ee7 100644 --- a/doc/markdown/markdown.md +++ b/doc/markdown/markdown.md @@ -1,662 +1 @@ -# Markdown - -## Table of Contents - -**[GitLab Flavored Markdown](#gitlab-flavored-markdown-gfm)** - -* [Newlines](#newlines) -* [Multiple underscores in words](#multiple-underscores-in-words) -* [URL auto-linking](#url-auto-linking) -* [Multiline Blockquote](#multiline-blockquote) -* [Code and Syntax Highlighting](#code-and-syntax-highlighting) -* [Inline Diff](#inline-diff) -* [Emoji](#emoji) -* [Special GitLab references](#special-gitlab-references) -* [Task Lists](#task-lists) -* [Videos](#videos) - -**[Standard Markdown](#standard-markdown)** - -* [Headers](#headers) -* [Emphasis](#emphasis) -* [Lists](#lists) -* [Links](#links) -* [Images](#images) -* [Blockquotes](#blockquotes) -* [Inline HTML](#inline-html) -* [Horizontal Rule](#horizontal-rule) -* [Line Breaks](#line-breaks) -* [Tables](#tables) - -**[References](#references)** - -## GitLab Flavored Markdown (GFM) - -_GitLab uses the [Redcarpet Ruby library][redcarpet] for Markdown processing._ - -GitLab uses "GitLab Flavored Markdown" (GFM). It extends the standard Markdown in a few significant ways to add some useful functionality. It was inspired by [GitHub Flavored Markdown](https://help.github.com/articles/basic-writing-and-formatting-syntax/). - -You can use GFM in - -- comments -- issues -- merge requests -- milestones -- wiki pages - -You can also use other rich text files in GitLab. You might have to install a dependency to do so. Please see the [github-markup gem readme](https://github.com/gitlabhq/markup#markups) for more information. - -## Newlines - -GFM honors the markdown specification in how [paragraphs and line breaks are handled](https://daringfireball.net/projects/markdown/syntax#p). - -A paragraph is simply one or more consecutive lines of text, separated by one or more blank lines. -Line-breaks, or softreturns, are rendered if you end a line with two or more spaces - - Roses are red [followed by two or more spaces] - Violets are blue - - Sugar is sweet - -Roses are red -Violets are blue - -Sugar is sweet - -## Multiple underscores in words - -It is not reasonable to italicize just _part_ of a word, especially when you're dealing with code and names that often appear with multiple underscores. Therefore, GFM ignores multiple underscores in words. - - perform_complicated_task - do_this_and_do_that_and_another_thing - -perform_complicated_task -do_this_and_do_that_and_another_thing - -## URL auto-linking - -GFM will autolink almost any URL you copy and paste into your text. - - * https://www.google.com - * https://google.com/ - * ftp://ftp.us.debian.org/debian/ - * smb://foo/bar/baz - * irc://irc.freenode.net/gitlab - * http://localhost:3000 - -* https://www.google.com -* https://google.com/ -* ftp://ftp.us.debian.org/debian/ -* smb://foo/bar/baz -* irc://irc.freenode.net/gitlab -* http://localhost:3000 - -## Multiline Blockquote - -On top of standard Markdown [blockquotes](#blockquotes), which require prepending `>` to quoted lines, -GFM supports multiline blockquotes fenced by <code>>>></code>. - -```no-highlight ->>> -If you paste a message from somewhere else - -that - -spans - -multiple lines, - -you can quote that without having to manually prepend `>` to every line! ->>> -``` - ->>> -If you paste a message from somewhere else - -that - -spans - -multiple lines, - -you can quote that without having to manually prepend `>` to every line! ->>> - -## Code and Syntax Highlighting - -_GitLab uses the [Rouge Ruby library][rouge] for syntax highlighting. For a -list of supported languages visit the Rouge website._ - -Blocks of code are either fenced by lines with three back-ticks <code>```</code>, or are indented with four spaces. Only the fenced code blocks support syntax highlighting. - -```no-highlight -Inline `code` has `back-ticks around` it. -``` - -Inline `code` has `back-ticks around` it. - -Example: - - ```javascript - var s = "JavaScript syntax highlighting"; - alert(s); - ``` - - ```python - def function(): - #indenting works just fine in the fenced code block - s = "Python syntax highlighting" - print s - ``` - - ```ruby - require 'redcarpet' - markdown = Redcarpet.new("Hello World!") - puts markdown.to_html - ``` - - ``` - No language indicated, so no syntax highlighting. - s = "There is no highlighting for this." - But let's throw in a <b>tag</b>. - ``` - -becomes: - -```javascript -var s = "JavaScript syntax highlighting"; -alert(s); -``` - -```python -def function(): - #indenting works just fine in the fenced code block - s = "Python syntax highlighting" - print s -``` - -```ruby -require 'redcarpet' -markdown = Redcarpet.new("Hello World!") -puts markdown.to_html -``` - -``` -No language indicated, so no syntax highlighting. -s = "There is no highlighting for this." -But let's throw in a <b>tag</b>. -``` - -## Inline Diff - -With inline diffs tags you can display {+ additions +} or [- deletions -]. - -The wrapping tags can be either curly braces or square brackets [+ additions +] or {- deletions -}. - -However the wrapping tags cannot be mixed as such: - -- {+ additions +] -- [+ additions +} -- {- deletions -] -- [- deletions -} - -## Emoji - - Sometimes you want to :monkey: around a bit and add some :star2: to your :speech_balloon:. Well we have a gift for you: - - :zap: You can use emoji anywhere GFM is supported. :v: - - You can use it to point out a :bug: or warn about :speak_no_evil: patches. And if someone improves your really :snail: code, send them some :birthday:. People will :heart: you for that. - - If you are new to this, don't be :fearful:. You can easily join the emoji :family:. All you need to do is to look up on the supported codes. - - Consult the [Emoji Cheat Sheet](http://emoji.codes) for a list of all supported emoji codes. :thumbsup: - -Sometimes you want to :monkey: around a bit and add some :star2: to your :speech_balloon:. Well we have a gift for you: - -:zap: You can use emoji anywhere GFM is supported. :v: - -You can use it to point out a :bug: or warn about :speak_no_evil: patches. And if someone improves your really :snail: code, send them some :birthday:. People will :heart: you for that. - -If you are new to this, don't be :fearful:. You can easily join the emoji :family:. All you need to do is to look up on the supported codes. - -Consult the [Emoji Cheat Sheet](http://emoji.codes) for a list of all supported emoji codes. :thumbsup: - -## Special GitLab References - -GFM recognizes special references. - -You can easily reference e.g. an issue, a commit, a team member or even the whole team within a project. - -GFM will turn that reference into a link so you can navigate between them easily. - -GFM will recognize the following: - -| input | references | -|:-----------------------|:--------------------------- | -| `@user_name` | specific user | -| `@group_name` | specific group | -| `@all` | entire team | -| `#123` | issue | -| `!123` | merge request | -| `$123` | snippet | -| `~123` | label by ID | -| `~bug` | one-word label by name | -| `~"feature request"` | multi-word label by name | -| `%123` | milestone by ID | -| `%v1.23` | one-word milestone by name | -| `%"release candidate"` | multi-word milestone by name | -| `9ba12248` | specific commit | -| `9ba12248...b19a04f5` | commit range comparison | -| `[README](doc/README)` | repository file references | - -GFM also recognizes certain cross-project references: - -| input | references | -|:----------------------------------------|:------------------------| -| `namespace/project#123` | issue | -| `namespace/project!123` | merge request | -| `namespace/project%123` | milestone | -| `namespace/project$123` | snippet | -| `namespace/project@9ba12248` | specific commit | -| `namespace/project@9ba12248...b19a04f5` | commit range comparison | -| `namespace/project~"Some label"` | issues with given label | - -## Task Lists - -You can add task lists to issues, merge requests and comments. To create a task list, add a specially-formatted Markdown list, like so: - -```no-highlight -- [x] Completed task -- [ ] Incomplete task - - [ ] Sub-task 1 - - [x] Sub-task 2 - - [ ] Sub-task 3 -``` - -- [x] Completed task -- [ ] Incomplete task - - [ ] Sub-task 1 - - [x] Sub-task 2 - - [ ] Sub-task 3 - -Task lists can only be created in descriptions, not in titles. Task item state can be managed by editing the description's Markdown or by toggling the rendered check boxes. - -## Videos - -Image tags with a video extension are automatically converted to a video player. - -The valid video extensions are `.mp4`, `.m4v`, `.mov`, `.webm`, and `.ogv`. - - Here's a sample video: - - ![Sample Video](img/video.mp4) - -Here's a sample video: - -![Sample Video](img/video.mp4) - -# Standard Markdown - -## Headers - -```no-highlight -# H1 -## H2 -### H3 -#### H4 -##### H5 -###### H6 - -Alternatively, for H1 and H2, an underline-ish style: - -Alt-H1 -====== - -Alt-H2 ------- -``` - -# H1 -## H2 -### H3 -#### H4 -##### H5 -###### H6 - -Alternatively, for H1 and H2, an underline-ish style: - -Alt-H1 -====== - -Alt-H2 ------- - -### Header IDs and links - -All Markdown-rendered headers automatically get IDs, except in comments. - -On hover a link to those IDs becomes visible to make it easier to copy the link to the header to give it to someone else. - -The IDs are generated from the content of the header according to the following rules: - -1. All text is converted to lowercase -1. All non-word text (e.g., punctuation, HTML) is removed -1. All spaces are converted to hyphens -1. Two or more hyphens in a row are converted to one -1. If a header with the same ID has already been generated, a unique - incrementing number is appended, starting at 1. - -For example: - -``` -# This header has spaces in it -## This header has a :thumbsup: in it -# This header has Unicode in it: 한글 -## This header has spaces in it -### This header has spaces in it -``` - -Would generate the following link IDs: - -1. `this-header-has-spaces-in-it` -1. `this-header-has-a-in-it` -1. `this-header-has-unicode-in-it-한글` -1. `this-header-has-spaces-in-it` -1. `this-header-has-spaces-in-it-1` - -Note that the Emoji processing happens before the header IDs are generated, so the Emoji is converted to an image which then gets removed from the ID. - -## Emphasis - -```no-highlight -Emphasis, aka italics, with *asterisks* or _underscores_. - -Strong emphasis, aka bold, with **asterisks** or __underscores__. - -Combined emphasis with **asterisks and _underscores_**. - -Strikethrough uses two tildes. ~~Scratch this.~~ -``` - -Emphasis, aka italics, with *asterisks* or _underscores_. - -Strong emphasis, aka bold, with **asterisks** or __underscores__. - -Combined emphasis with **asterisks and _underscores_**. - -Strikethrough uses two tildes. ~~Scratch this.~~ - -## Lists - -```no-highlight -1. First ordered list item -2. Another item - * Unordered sub-list. -1. Actual numbers don't matter, just that it's a number - 1. Ordered sub-list -4. And another item. - -* Unordered list can use asterisks -- Or minuses -+ Or pluses -``` - -1. First ordered list item -2. Another item - * Unordered sub-list. -1. Actual numbers don't matter, just that it's a number - 1. Ordered sub-list -4. And another item. - -* Unordered list can use asterisks -- Or minuses -+ Or pluses - -If a list item contains multiple paragraphs, -each subsequent paragraph should be indented with four spaces. - -```no-highlight -1. First ordered list item - - Second paragraph of first item. -2. Another item -``` - -1. First ordered list item - - Second paragraph of first item. -2. Another item - -If the second paragraph isn't indented with four spaces, -the second list item will be incorrectly labeled as `1`. - -```no-highlight -1. First ordered list item - - Second paragraph of first item. -2. Another item -``` - -1. First ordered list item - - Second paragraph of first item. -2. Another item - -## Links - -There are two ways to create links, inline-style and reference-style. - - [I'm an inline-style link](https://www.google.com) - - [I'm a reference-style link][Arbitrary case-insensitive reference text] - - [I'm a relative reference to a repository file](LICENSE) - - [You can use numbers for reference-style link definitions][1] - - Or leave it empty and use the [link text itself][] - - Some text to show that the reference links can follow later. - - [arbitrary case-insensitive reference text]: https://www.mozilla.org - [1]: http://slashdot.org - [link text itself]: https://www.reddit.com - -[I'm an inline-style link](https://www.google.com) - -[I'm a reference-style link][Arbitrary case-insensitive reference text] - -[I'm a relative reference to a repository file](LICENSE)[^1] - -[You can use numbers for reference-style link definitions][1] - -Or leave it empty and use the [link text itself][] - -Some text to show that the reference links can follow later. - -[arbitrary case-insensitive reference text]: https://www.mozilla.org -[1]: http://slashdot.org -[link text itself]: https://www.reddit.com - -**Note** - -Relative links do not allow referencing project files in a wiki page or wiki page in a project file. The reason for this is that, in GitLab, wiki is always a separate git repository. For example: - -`[I'm a reference-style link](style)` - -will point the link to `wikis/style` when the link is inside of a wiki markdown file. - -## Images - - Here's our logo (hover to see the title text): - - Inline-style: - ![alt text](img/logo.png) - - Reference-style: - ![alt text1][logo] - - [logo]: img/logo.png - -Here's our logo: - -Inline-style: - -![alt text](img/logo.png) - -Reference-style: - -![alt text][logo] - -[logo]: img/logo.png - -## Blockquotes - -```no-highlight -> Blockquotes are very handy in email to emulate reply text. -> This line is part of the same quote. - -Quote break. - -> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. -``` - -> Blockquotes are very handy in email to emulate reply text. -> This line is part of the same quote. - -Quote break. - -> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. - -## Inline HTML - -You can also use raw HTML in your Markdown, and it'll mostly work pretty well. - -See the documentation for HTML::Pipeline's [SanitizationFilter](http://www.rubydoc.info/gems/html-pipeline/HTML/Pipeline/SanitizationFilter#WHITELIST-constant) class for the list of allowed HTML tags and attributes. In addition to the default `SanitizationFilter` whitelist, GitLab allows `span` elements. - -```no-highlight -<dl> - <dt>Definition list</dt> - <dd>Is something people use sometimes.</dd> - - <dt>Markdown in HTML</dt> - <dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd> -</dl> -``` - -<dl> - <dt>Definition list</dt> - <dd>Is something people use sometimes.</dd> - - <dt>Markdown in HTML</dt> - <dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd> -</dl> - -## Horizontal Rule - -``` -Three or more... - ---- - -Hyphens - -*** - -Asterisks - -___ - -Underscores -``` - -Three or more... - ---- - -Hyphens - -*** - -Asterisks - -___ - -Underscores - -## Line Breaks - -My basic recommendation for learning how line breaks work is to experiment and discover -- hit <Enter> once (i.e., insert one newline), then hit it twice (i.e., insert two newlines), see what happens. You'll soon learn to get what you want. "Markdown Toggle" is your friend. - -Here are some things to try out: - -``` -Here's a line for us to start with. - -This line is separated from the one above by two newlines, so it will be a *separate paragraph*. - -This line is also a separate paragraph, but... -This line is only separated by a single newline, so it's a separate line in the *same paragraph*. - -This line is also a separate paragraph, and... -This line is on its own line, because the previous line ends with two -spaces. -``` - -Here's a line for us to start with. - -This line is separated from the one above by two newlines, so it will be a *separate paragraph*. - -This line is also begins a separate paragraph, but... -This line is only separated by a single newline, so it's a separate line in the *same paragraph*. - -This line is also a separate paragraph, and... -This line is on its own line, because the previous line ends with two -spaces. - -## Tables - -Tables aren't part of the core Markdown spec, but they are part of GFM and Markdown Here supports them. - -``` -| header 1 | header 2 | -| -------- | -------- | -| cell 1 | cell 2 | -| cell 3 | cell 4 | -``` - -Code above produces next output: - -| header 1 | header 2 | -| -------- | -------- | -| cell 1 | cell 2 | -| cell 3 | cell 4 | - -**Note** - -The row of dashes between the table header and body must have at least three dashes in each column. - -By including colons in the header row, you can align the text within that column: - -``` -| Left Aligned | Centered | Right Aligned | Left Aligned | Centered | Right Aligned | -| :----------- | :------: | ------------: | :----------- | :------: | ------------: | -| Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 | Cell 6 | -| Cell 7 | Cell 8 | Cell 9 | Cell 10 | Cell 11 | Cell 12 | -``` - -| Left Aligned | Centered | Right Aligned | Left Aligned | Centered | Right Aligned | -| :----------- | :------: | ------------: | :----------- | :------: | ------------: | -| Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 | Cell 6 | -| Cell 7 | Cell 8 | Cell 9 | Cell 10 | Cell 11 | Cell 12 | - -## References - -- This document leveraged heavily from the [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet). -- The [Markdown Syntax Guide](https://daringfireball.net/projects/markdown/syntax) at Daring Fireball is an excellent resource for a detailed explanation of standard markdown. -- [Dillinger.io](http://dillinger.io) is a handy tool for testing standard markdown. - -[rouge]: http://rouge.jneen.net/ "Rouge website" -[redcarpet]: https://github.com/vmg/redcarpet "Redcarpet website" -[^1]: This link will be broken if you see this document from the Help page or docs.gitlab.com +This document was moved to [user/markdown.md](../user/markdown.md). diff --git a/doc/monitoring/health_check.md b/doc/monitoring/health_check.md index 0d17799372f..70326f1ff80 100644 --- a/doc/monitoring/health_check.md +++ b/doc/monitoring/health_check.md @@ -1,6 +1,6 @@ # Health Check ->**Note:** This feature was [introduced][ce-3888] in GitLab 8.8. +> [Introduced][ce-3888] in GitLab 8.8. GitLab provides a health check endpoint for uptime monitoring on the `health_check` web endpoint. The health check reports on the overall system status based on the status of diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index fa976134341..5fa96736d59 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -382,6 +382,13 @@ backups using all your disk space. To do this add the following lines to gitlab_rails['backup_keep_time'] = 604800 ``` +Note that the `backup_keep_time` configuration option only manages local +files. GitLab does not automatically prune old files stored in a third-party +object storage (e.g. AWS S3) because the user may not have permission to list +and delete files. We recommend that you configure the appropriate retention +policy for your object storage. For example, you can configure [the S3 backup +policy here as described here](http://stackoverflow.com/questions/37553070/gitlab-omnibus-delete-backup-from-amazon-s3). + NOTE: This cron job does not [backup your omnibus-gitlab configuration](#backup-and-restore-omnibus-gitlab-configuration) or [SSH host keys](https://superuser.com/questions/532040/copy-ssh-keys-from-one-server-to-another-server/532079#532079). ## Alternative backup strategies diff --git a/doc/update/8.10-to-8.11.md b/doc/update/8.10-to-8.11.md new file mode 100644 index 00000000000..25343d484ba --- /dev/null +++ b/doc/update/8.10-to-8.11.md @@ -0,0 +1,167 @@ +# From 8.10 to 8.11 + +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + + sudo service gitlab stop + +### 2. Backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Get latest code + +```bash +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +sudo -u git -H git checkout 8-11-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +sudo -u git -H git checkout 8-11-stable-ee +``` + +### 4. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v3.2.1 +``` + +### 5. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. + +```bash +cd /home/git/gitlab-workhorse +sudo -u git -H git fetch --all +sudo -u git -H git checkout v0.7.8 +sudo -u git -H make +``` + +### 6. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Clean up assets and cache +sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production + +``` + +### 7. Update configuration files + +#### New configuration options for `gitlab.yml` + +There are new configuration options available for [`gitlab.yml`](config/gitlab.yml.example). View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +git diff origin/8-10-stable:config/gitlab.yml.example origin/8-11-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +# For HTTPS configurations +git diff origin/8-10-stable:lib/support/nginx/gitlab-ssl origin/8-11-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/8-10-stable:lib/support/nginx/gitlab origin/8-11-stable:lib/support/nginx/gitlab +``` + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-11-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-11-stable/config/initializers/smtp_settings.rb.sample#L13? + +#### Init script + +Ensure you're still up-to-date with the latest init script changes: + + sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +### 8. Start application + + sudo service gitlab start + sudo service nginx restart + +### 9. Check application status + +Check if GitLab and its environment are configured correctly: + + sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production + +To make sure you didn't miss anything run a more thorough check: + + sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (8.10) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 8.9 to 8.10](8.9-to-8.10.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. diff --git a/doc/update/8.9-to-8.10.md b/doc/update/8.9-to-8.10.md index 71cbe5c8ac6..a057a423e61 100644 --- a/doc/update/8.9-to-8.10.md +++ b/doc/update/8.9-to-8.10.md @@ -46,7 +46,7 @@ sudo -u git -H git checkout 8-10-stable-ee ```bash cd /home/git/gitlab-shell sudo -u git -H git fetch --all --tags -sudo -u git -H git checkout v3.2.0 +sudo -u git -H git checkout v3.2.1 ``` ### 5. Update gitlab-workhorse diff --git a/doc/user/admin_area/settings/continuous_integration.md b/doc/user/admin_area/settings/continuous_integration.md new file mode 100644 index 00000000000..34e2e557f89 --- /dev/null +++ b/doc/user/admin_area/settings/continuous_integration.md @@ -0,0 +1,20 @@ +# Continuous integration Admin settings + +## Maximum artifacts size + +The maximum size of the [build artifacts][art-yml] can be set in the Admin area +of your GitLab instance. The value is in MB and the default is 100MB. Note that +this setting is set for each build. + +1. Go to **Admin area > Settings** (`/admin/application_settings`). + + ![Admin area settings button](img/admin_area_settings_button.png) + +1. Change the value of the maximum artifacts size (in MB): + + ![Admin area maximum artifacts size](img/admin_area_maximum_artifacts_size.png) + +1. Hit **Save** for the changes to take effect. + + +[art-yml]: ../../../administration/build_artifacts.md diff --git a/doc/user/admin_area/settings/img/admin_area_maximum_artifacts_size.png b/doc/user/admin_area/settings/img/admin_area_maximum_artifacts_size.png Binary files differnew file mode 100644 index 00000000000..53f7e76033e --- /dev/null +++ b/doc/user/admin_area/settings/img/admin_area_maximum_artifacts_size.png diff --git a/doc/user/admin_area/settings/img/admin_area_settings_button.png b/doc/user/admin_area/settings/img/admin_area_settings_button.png Binary files differnew file mode 100644 index 00000000000..509708b627f --- /dev/null +++ b/doc/user/admin_area/settings/img/admin_area_settings_button.png diff --git a/doc/markdown/img/logo.png b/doc/user/img/markdown_logo.png Binary files differindex 05c8b0d0ccf..05c8b0d0ccf 100644 --- a/doc/markdown/img/logo.png +++ b/doc/user/img/markdown_logo.png diff --git a/doc/markdown/img/video.mp4 b/doc/user/img/markdown_video.mp4 Binary files differindex 1fc478842f5..1fc478842f5 100644 --- a/doc/markdown/img/video.mp4 +++ b/doc/user/img/markdown_video.mp4 diff --git a/doc/user/markdown.md b/doc/user/markdown.md new file mode 100644 index 00000000000..7fe96e67dbb --- /dev/null +++ b/doc/user/markdown.md @@ -0,0 +1,786 @@ +# Markdown + +## Table of Contents + +**[GitLab Flavored Markdown](#gitlab-flavored-markdown-gfm)** + +* [Newlines](#newlines) +* [Multiple underscores in words](#multiple-underscores-in-words) +* [URL auto-linking](#url-auto-linking) +* [Multiline Blockquote](#multiline-blockquote) +* [Code and Syntax Highlighting](#code-and-syntax-highlighting) +* [Inline Diff](#inline-diff) +* [Emoji](#emoji) +* [Special GitLab references](#special-gitlab-references) +* [Task Lists](#task-lists) +* [Videos](#videos) + +**[Standard Markdown](#standard-markdown)** + +* [Headers](#headers) +* [Emphasis](#emphasis) +* [Lists](#lists) +* [Links](#links) +* [Images](#images) +* [Blockquotes](#blockquotes) +* [Inline HTML](#inline-html) +* [Horizontal Rule](#horizontal-rule) +* [Line Breaks](#line-breaks) +* [Tables](#tables) + +**[Wiki-Specific Markdown](#wiki-specific-markdown)** + +* [Wiki - Direct page link](#wiki-direct-page-link) +* [Wiki - Direct file link](#wiki-direct-file-link) +* [Wiki - Hierarchical link](#wiki-hierarchical-link) +* [Wiki - Root link](#wiki-root-link) + +**[References](#references)** + +## GitLab Flavored Markdown (GFM) + +> **Note:** +Not all of the GitLab-specific extensions to Markdown that are described in +this document currently work on our documentation website. +> +For the best result, we encourage you to check this document out as rendered +by GitLab: [markdown.md] + +_GitLab uses the [Redcarpet Ruby library][redcarpet] for Markdown processing._ + +GitLab uses "GitLab Flavored Markdown" (GFM). It extends the standard Markdown in a few significant ways to add some useful functionality. It was inspired by [GitHub Flavored Markdown](https://help.github.com/articles/basic-writing-and-formatting-syntax/). + +You can use GFM in the following areas: + +- comments +- issues +- merge requests +- milestones +- snippets (the snippet must be named with a `.md` extension) +- wiki pages +- markdown documents inside the repository + +You can also use other rich text files in GitLab. You might have to install a +dependency to do so. Please see the [github-markup gem readme](https://github.com/gitlabhq/markup#markups) for more information. + +## Newlines + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#newlines + +GFM honors the markdown specification in how [paragraphs and line breaks are handled](https://daringfireball.net/projects/markdown/syntax#p). + +A paragraph is simply one or more consecutive lines of text, separated by one or more blank lines. +Line-breaks, or softreturns, are rendered if you end a line with two or more spaces: + + Roses are red [followed by two or more spaces] + Violets are blue + + Sugar is sweet + +Roses are red +Violets are blue + +Sugar is sweet + +## Multiple underscores in words + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#multiple-underscores-in-words + +It is not reasonable to italicize just _part_ of a word, especially when you're dealing with code and names that often appear with multiple underscores. Therefore, GFM ignores multiple underscores in words: + + perform_complicated_task + + do_this_and_do_that_and_another_thing + +perform_complicated_task + +do_this_and_do_that_and_another_thing + +## URL auto-linking + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#url-auto-linking + +GFM will autolink almost any URL you copy and paste into your text: + + * https://www.google.com + * https://google.com/ + * ftp://ftp.us.debian.org/debian/ + * smb://foo/bar/baz + * irc://irc.freenode.net/gitlab + * http://localhost:3000 + +* https://www.google.com +* https://google.com/ +* ftp://ftp.us.debian.org/debian/ +* smb://foo/bar/baz +* irc://irc.freenode.net/gitlab +* http://localhost:3000 + +## Multiline Blockquote + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#multiline-blockquote + +On top of standard Markdown [blockquotes](#blockquotes), which require prepending `>` to quoted lines, +GFM supports multiline blockquotes fenced by <code>>>></code>: + +```no-highlight +>>> +If you paste a message from somewhere else + +that + +spans + +multiple lines, + +you can quote that without having to manually prepend `>` to every line! +>>> +``` + +>>> +If you paste a message from somewhere else + +that + +spans + +multiple lines, + +you can quote that without having to manually prepend `>` to every line! +>>> + +## Code and Syntax Highlighting + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#code-and-syntax-highlighting + +_GitLab uses the [Rouge Ruby library][rouge] for syntax highlighting. For a +list of supported languages visit the Rouge website._ + +Blocks of code are either fenced by lines with three back-ticks <code>```</code>, +or are indented with four spaces. Only the fenced code blocks support syntax +highlighting: + +```no-highlight +Inline `code` has `back-ticks around` it. +``` + +Inline `code` has `back-ticks around` it. + +Example: + + ```javascript + var s = "JavaScript syntax highlighting"; + alert(s); + ``` + + ```python + def function(): + #indenting works just fine in the fenced code block + s = "Python syntax highlighting" + print s + ``` + + ```ruby + require 'redcarpet' + markdown = Redcarpet.new("Hello World!") + puts markdown.to_html + ``` + + ``` + No language indicated, so no syntax highlighting. + s = "There is no highlighting for this." + But let's throw in a <b>tag</b>. + ``` + +becomes: + +```javascript +var s = "JavaScript syntax highlighting"; +alert(s); +``` + +```python +def function(): + #indenting works just fine in the fenced code block + s = "Python syntax highlighting" + print s +``` + +```ruby +require 'redcarpet' +markdown = Redcarpet.new("Hello World!") +puts markdown.to_html +``` + +``` +No language indicated, so no syntax highlighting. +s = "There is no highlighting for this." +But let's throw in a <b>tag</b>. +``` + +## Inline Diff + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#inline-diff + +With inline diffs tags you can display {+ additions +} or [- deletions -]. + +The wrapping tags can be either curly braces or square brackets [+ additions +] or {- deletions -}. + +However the wrapping tags cannot be mixed as such: + +- {+ additions +] +- [+ additions +} +- {- deletions -] +- [- deletions -} + +## Emoji + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#emoji + + Sometimes you want to :monkey: around a bit and add some :star2: to your :speech_balloon:. Well we have a gift for you: + + :zap: You can use emoji anywhere GFM is supported. :v: + + You can use it to point out a :bug: or warn about :speak_no_evil: patches. And if someone improves your really :snail: code, send them some :birthday:. People will :heart: you for that. + + If you are new to this, don't be :fearful:. You can easily join the emoji :family:. All you need to do is to look up on the supported codes. + + Consult the [Emoji Cheat Sheet](http://emoji.codes) for a list of all supported emoji codes. :thumbsup: + +Sometimes you want to :monkey: around a bit and add some :star2: to your :speech_balloon:. Well we have a gift for you: + +:zap: You can use emoji anywhere GFM is supported. :v: + +You can use it to point out a :bug: or warn about :speak_no_evil: patches. And if someone improves your really :snail: code, send them some :birthday:. People will :heart: you for that. + +If you are new to this, don't be :fearful:. You can easily join the emoji :family:. All you need to do is to look up on the supported codes. + +Consult the [Emoji Cheat Sheet](http://emoji.codes) for a list of all supported emoji codes. :thumbsup: + +## Special GitLab References + +GFM recognizes special references. + +You can easily reference e.g. an issue, a commit, a team member or even the whole team within a project. + +GFM will turn that reference into a link so you can navigate between them easily. + +GFM will recognize the following: + +| input | references | +|:-----------------------|:--------------------------- | +| `@user_name` | specific user | +| `@group_name` | specific group | +| `@all` | entire team | +| `#123` | issue | +| `!123` | merge request | +| `$123` | snippet | +| `~123` | label by ID | +| `~bug` | one-word label by name | +| `~"feature request"` | multi-word label by name | +| `%123` | milestone by ID | +| `%v1.23` | one-word milestone by name | +| `%"release candidate"` | multi-word milestone by name | +| `9ba12248` | specific commit | +| `9ba12248...b19a04f5` | commit range comparison | +| `[README](doc/README)` | repository file references | + +GFM also recognizes certain cross-project references: + +| input | references | +|:----------------------------------------|:------------------------| +| `namespace/project#123` | issue | +| `namespace/project!123` | merge request | +| `namespace/project%123` | milestone | +| `namespace/project$123` | snippet | +| `namespace/project@9ba12248` | specific commit | +| `namespace/project@9ba12248...b19a04f5` | commit range comparison | +| `namespace/project~"Some label"` | issues with given label | + +## Task Lists + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#task-lists + +You can add task lists to issues, merge requests and comments. To create a task list, add a specially-formatted Markdown list, like so: + +```no-highlight +- [x] Completed task +- [ ] Incomplete task + - [ ] Sub-task 1 + - [x] Sub-task 2 + - [ ] Sub-task 3 +``` + +- [x] Completed task +- [ ] Incomplete task + - [ ] Sub-task 1 + - [x] Sub-task 2 + - [ ] Sub-task 3 + +Task lists can only be created in descriptions, not in titles. Task item state can be managed by editing the description's Markdown or by toggling the rendered check boxes. + +## Videos + +> If this is not rendered correctly, see +https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md#videos + +Image tags with a video extension are automatically converted to a video player. + +The valid video extensions are `.mp4`, `.m4v`, `.mov`, `.webm`, and `.ogv`. + + Here's a sample video: + + ![Sample Video](img/markdown_video.mp4) + +Here's a sample video: + +![Sample Video](img/markdown_video.mp4) + +# Standard Markdown + +## Headers + +```no-highlight +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +Alternatively, for H1 and H2, an underline-ish style: + +Alt-H1 +====== + +Alt-H2 +------ +``` + +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +Alternatively, for H1 and H2, an underline-ish style: + +Alt-H1 +====== + +Alt-H2 +------ + +### Header IDs and links + +All Markdown-rendered headers automatically get IDs, except in comments. + +On hover a link to those IDs becomes visible to make it easier to copy the link to the header to give it to someone else. + +The IDs are generated from the content of the header according to the following rules: + +1. All text is converted to lowercase +1. All non-word text (e.g., punctuation, HTML) is removed +1. All spaces are converted to hyphens +1. Two or more hyphens in a row are converted to one +1. If a header with the same ID has already been generated, a unique + incrementing number is appended, starting at 1. + +For example: + +``` +# This header has spaces in it +## This header has a :thumbsup: in it +# This header has Unicode in it: 한글 +## This header has spaces in it +### This header has spaces in it +``` + +Would generate the following link IDs: + +1. `this-header-has-spaces-in-it` +1. `this-header-has-a-in-it` +1. `this-header-has-unicode-in-it-한글` +1. `this-header-has-spaces-in-it` +1. `this-header-has-spaces-in-it-1` + +Note that the Emoji processing happens before the header IDs are generated, so the Emoji is converted to an image which then gets removed from the ID. + +## Emphasis + +```no-highlight +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ +``` + +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ + +## Lists + +```no-highlight +1. First ordered list item +2. Another item + * Unordered sub-list. +1. Actual numbers don't matter, just that it's a number + 1. Ordered sub-list +4. And another item. + +* Unordered list can use asterisks +- Or minuses ++ Or pluses +``` + +1. First ordered list item +2. Another item + * Unordered sub-list. +1. Actual numbers don't matter, just that it's a number + 1. Ordered sub-list +4. And another item. + +* Unordered list can use asterisks +- Or minuses ++ Or pluses + +If a list item contains multiple paragraphs, +each subsequent paragraph should be indented with four spaces. + +```no-highlight +1. First ordered list item + + Second paragraph of first item. +2. Another item +``` + +1. First ordered list item + + Second paragraph of first item. +2. Another item + +If the second paragraph isn't indented with four spaces, +the second list item will be incorrectly labeled as `1`. + +```no-highlight +1. First ordered list item + + Second paragraph of first item. +2. Another item +``` + +1. First ordered list item + + Second paragraph of first item. +2. Another item + +## Links + +There are two ways to create links, inline-style and reference-style. + + [I'm an inline-style link](https://www.google.com) + + [I'm a reference-style link][Arbitrary case-insensitive reference text] + + [I'm a relative reference to a repository file](LICENSE) + + [You can use numbers for reference-style link definitions][1] + + Or leave it empty and use the [link text itself][] + + Some text to show that the reference links can follow later. + + [arbitrary case-insensitive reference text]: https://www.mozilla.org + [1]: http://slashdot.org + [link text itself]: https://www.reddit.com + +[I'm an inline-style link](https://www.google.com) + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[I'm a relative reference to a repository file](LICENSE)[^1] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself][] + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: https://www.reddit.com + +**Note** + +Relative links do not allow referencing project files in a wiki page or wiki page in a project file. The reason for this is that, in GitLab, wiki is always a separate git repository. For example: + +`[I'm a reference-style link](style)` + +will point the link to `wikis/style` when the link is inside of a wiki markdown file. + +## Images + + Here's our logo (hover to see the title text): + + Inline-style: + ![alt text](img/markdown_logo.png) + + Reference-style: + ![alt text1][logo] + + [logo]: img/markdown_logo.png + +Here's our logo: + +Inline-style: + +![alt text](img/markdown_logo.png) + +Reference-style: + +![alt text][logo] + +[logo]: img/markdown_logo.png + +## Blockquotes + +```no-highlight +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. +``` + +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. + +## Inline HTML + +You can also use raw HTML in your Markdown, and it'll mostly work pretty well. + +See the documentation for HTML::Pipeline's [SanitizationFilter](http://www.rubydoc.info/gems/html-pipeline/HTML/Pipeline/SanitizationFilter#WHITELIST-constant) class for the list of allowed HTML tags and attributes. In addition to the default `SanitizationFilter` whitelist, GitLab allows `span` elements. + +```no-highlight +<dl> + <dt>Definition list</dt> + <dd>Is something people use sometimes.</dd> + + <dt>Markdown in HTML</dt> + <dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd> +</dl> +``` + +<dl> + <dt>Definition list</dt> + <dd>Is something people use sometimes.</dd> + + <dt>Markdown in HTML</dt> + <dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd> +</dl> + +## Horizontal Rule + +``` +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores +``` + +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores + +## Line Breaks + +My basic recommendation for learning how line breaks work is to experiment and discover -- hit <Enter> once (i.e., insert one newline), then hit it twice (i.e., insert two newlines), see what happens. You'll soon learn to get what you want. "Markdown Toggle" is your friend. + +Here are some things to try out: + +``` +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. + +This line is also a separate paragraph, and... +This line is on its own line, because the previous line ends with two +spaces. +``` + +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also begins a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. + +This line is also a separate paragraph, and... +This line is on its own line, because the previous line ends with two +spaces. + +## Tables + +Tables aren't part of the core Markdown spec, but they are part of GFM and Markdown Here supports them. + +``` +| header 1 | header 2 | +| -------- | -------- | +| cell 1 | cell 2 | +| cell 3 | cell 4 | +``` + +Code above produces next output: + +| header 1 | header 2 | +| -------- | -------- | +| cell 1 | cell 2 | +| cell 3 | cell 4 | + +**Note** + +The row of dashes between the table header and body must have at least three dashes in each column. + +By including colons in the header row, you can align the text within that column: + +``` +| Left Aligned | Centered | Right Aligned | Left Aligned | Centered | Right Aligned | +| :----------- | :------: | ------------: | :----------- | :------: | ------------: | +| Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 | Cell 6 | +| Cell 7 | Cell 8 | Cell 9 | Cell 10 | Cell 11 | Cell 12 | +``` + +| Left Aligned | Centered | Right Aligned | Left Aligned | Centered | Right Aligned | +| :----------- | :------: | ------------: | :----------- | :------: | ------------: | +| Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 | Cell 6 | +| Cell 7 | Cell 8 | Cell 9 | Cell 10 | Cell 11 | Cell 12 | + + +## Wiki-specific Markdown + +The following examples show how links inside wikis behave. + +### Wiki - Direct page link + +A link which just includes the slug for a page will point to that page, +_at the base level of the wiki_. + +This snippet would link to a `documentation` page at the root of your wiki: + +```markdown +[Link to Documentation](documentation) +``` + +### Wiki - Direct file link + +Links with a file extension point to that file, _relative to the current page_. + +If this snippet was placed on a page at `<your_wiki>/documentation/related`, +it would link to `<your_wiki>/documentation/file.md`: + +```markdown +[Link to File](file.md) +``` + +### Wiki - Hierarchical link + +A link can be constructed relative to the current wiki page using `./<page>`, +`../<page>`, etc. + +- If this snippet was placed on a page at `<your_wiki>/documentation/main`, + it would link to `<your_wiki>/documentation/related`: + + ```markdown + [Link to Related Page](./related) + ``` + +- If this snippet was placed on a page at `<your_wiki>/documentation/related/content`, + it would link to `<your_wiki>/documentation/main`: + + ```markdown + [Link to Related Page](../main) + ``` + +- If this snippet was placed on a page at `<your_wiki>/documentation/main`, + it would link to `<your_wiki>/documentation/related.md`: + + ```markdown + [Link to Related Page](./related.md) + ``` + +- If this snippet was placed on a page at `<your_wiki>/documentation/related/content`, + it would link to `<your_wiki>/documentation/main.md`: + + ```markdown + [Link to Related Page](../main.md) + ``` + +### Wiki - Root link + +A link starting with a `/` is relative to the wiki root. + +- This snippet links to `<wiki_root>/documentation`: + + ```markdown + [Link to Related Page](/documentation) + ``` + +- This snippet links to `<wiki_root>/miscellaneous.md`: + + ```markdown + [Link to Related Page](/miscellaneous.md) + ``` +## References + +- This document leveraged heavily from the [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet). +- The [Markdown Syntax Guide](https://daringfireball.net/projects/markdown/syntax) at Daring Fireball is an excellent resource for a detailed explanation of standard markdown. +- [Dillinger.io](http://dillinger.io) is a handy tool for testing standard markdown. + +[markdown.md]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/markdown/markdown.md +[rouge]: http://rouge.jneen.net/ "Rouge website" +[redcarpet]: https://github.com/vmg/redcarpet "Redcarpet website" +[^1]: This link will be broken if you see this document from the Help page or docs.gitlab.com diff --git a/doc/user/project/builds/artifacts.md b/doc/user/project/builds/artifacts.md new file mode 100644 index 00000000000..c93ae1c369c --- /dev/null +++ b/doc/user/project/builds/artifacts.md @@ -0,0 +1,104 @@ +# Introduction to build artifacts + +>**Notes:** +>- Since GitLab 8.2 and GitLab Runner 0.7.0, build artifacts that are created by + GitLab Runner are uploaded to GitLab and are downloadable as a single archive + (`tar.gz`) using the GitLab UI. +>- Starting from GitLab 8.4 and GitLab Runner 1.0, the artifacts archive format + changed to `ZIP`, and it is now possible to browse its contents, with the added + ability of downloading the files separately. +>- The artifacts browser will be available only for new artifacts that are sent + to GitLab using GitLab Runner version 1.0 and up. It will not be possible to + browse old artifacts already uploaded to GitLab. +>- This is the user documentation. For the administration guide see + [administration/build_artifacts.md](../../../administration/build_artifacts.md). + +Artifacts is a list of files and directories which are attached to a build +after it completes successfully. This feature is enabled by default in all GitLab installations. + +## Defining artifacts in `.gitlab-ci.yml` + +A simple example of using the artifacts definition in `.gitlab-ci.yml` would be +the following: + +```yaml +pdf: + script: xelatex mycv.tex + artifacts: + paths: + - mycv.pdf +``` + +A job named `pdf` calls the `xelatex` command in order to build a pdf file from +the latex source file `mycv.tex`. We then define the `artifacts` paths which in +turn are defined with the `paths` keyword. All paths to files and directories +are relative to the repository that was cloned during the build. + +For more examples on artifacts, follow the artifacts reference in +[`.gitlab-ci.yml` documentation](../../../ci/yaml/README.md#artifacts). + +## Browsing build artifacts + +When GitLab receives an artifacts archive, an archive metadata file is also +generated. This metadata file describes all the entries that are located in the +artifacts archive itself. The metadata file is in a binary format, with +additional GZIP compression. + +GitLab does not extract the artifacts archive in order to save space, memory +and disk I/O. It instead inspects the metadata file which contains all the +relevant information. This is especially important when there is a lot of +artifacts, or an archive is a very large file. + +--- + +After a build finishes, if you visit the build's specific page, you can see +that there are two buttons. One is for downloading the artifacts archive and +the other for browsing its contents. + +![Build artifacts browser button](img/build_artifacts_browser_button.png) + +--- + +The archive browser shows the name and the actual file size of each file in the +archive. If your artifacts contained directories, then you are also able to +browse inside them. + +Below you can see how browsing looks like. In this case we have browsed inside +the archive and at this point there is one directory and one HTML file. + +![Build artifacts browser](img/build_artifacts_browser.png) + +--- + +## Downloading build artifacts + +>**Note:** +GitLab does not extract the entire artifacts archive to send just a single file +to the user. When clicking on a specific file, [GitLab Workhorse] extracts it +from the archive and the download begins. This implementation saves space, +memory and disk I/O. + +If you need to download the whole archive, there are buttons in various places +inside GitLab that make that possible. + +1. While on the pipelines page, you can see the download icon for each build's + artifacts archive in the right corner: + + ![Build artifacts in Pipelines page](img/build_artifacts_pipelines_page.png) + +1. While on the builds page, you can see the download icon for each build's + artifacts archive in the right corner: + + ![Build artifacts in Builds page](img/build_artifacts_builds_page.png) + +1. While inside a specific build, you are presented with a download button + along with the one that browses the archive: + + ![Build artifacts browser button](img/build_artifacts_browser_button.png) + +1. And finally, when browsing an archive you can see the download button at + the top right corner: + + ![Build artifacts browser](img/build_artifacts_browser.png) + +[gitlab workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse "GitLab Workhorse repository" diff --git a/doc/user/project/builds/img/build_artifacts_browser.png b/doc/user/project/builds/img/build_artifacts_browser.png Binary files differnew file mode 100644 index 00000000000..d95e2800c0f --- /dev/null +++ b/doc/user/project/builds/img/build_artifacts_browser.png diff --git a/doc/user/project/builds/img/build_artifacts_browser_button.png b/doc/user/project/builds/img/build_artifacts_browser_button.png Binary files differnew file mode 100644 index 00000000000..463540634e3 --- /dev/null +++ b/doc/user/project/builds/img/build_artifacts_browser_button.png diff --git a/doc/user/project/builds/img/build_artifacts_builds_page.png b/doc/user/project/builds/img/build_artifacts_builds_page.png Binary files differnew file mode 100644 index 00000000000..db78386ba7b --- /dev/null +++ b/doc/user/project/builds/img/build_artifacts_builds_page.png diff --git a/doc/user/project/builds/img/build_artifacts_pipelines_page.png b/doc/user/project/builds/img/build_artifacts_pipelines_page.png Binary files differnew file mode 100644 index 00000000000..6c2d1a4bdc7 --- /dev/null +++ b/doc/user/project/builds/img/build_artifacts_pipelines_page.png diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md index 4258185b7d0..1259a16330b 100644 --- a/doc/user/project/labels.md +++ b/doc/user/project/labels.md @@ -46,10 +46,11 @@ When you are ready press the **Create label** button to create the new label. ## Prioritize labels >**Notes:** - - This feature was introduced in GitLab 8.9. - - Priority sorting is based on the highest priority label only. This might - change in the future, follow the discussion in - https://gitlab.com/gitlab-org/gitlab-ce/issues/18554. +> +> - Introduced in GitLab 8.9. +> - Priority sorting is based on the highest priority label only. This might +> change in the future, follow the discussion in +> https://gitlab.com/gitlab-org/gitlab-ce/issues/18554. Prioritized labels are like any other label, but sorted by priority. This allows you to sort issues and merge requests by priority. @@ -87,8 +88,7 @@ important. ## Create a new label right from the issue tracker ->**Note:** -This feature was introduced in GitLab 8.6. +> Introduced in GitLab 8.6. There are times when you are already in the issue tracker searching for a label, only to realize it doesn't exist. Instead of going to the **Labels** diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md index 6a8170b5ecb..96d9bdc1b29 100644 --- a/doc/user/project/protected_branches.md +++ b/doc/user/project/protected_branches.md @@ -47,8 +47,7 @@ creation. ## Wildcard protected branches ->**Note:** -This feature was [introduced][ce-4665] in GitLab 8.10. +> [Introduced][ce-4665] in GitLab 8.10. You can specify a wildcard protected branch, which will protect all branches matching the wildcard. For example: diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 38e9786123d..2513def49a4 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -1,18 +1,19 @@ # Project import/export >**Notes:** - - This feature was [introduced][ce-3050] in GitLab 8.9 - - Importing will not be possible if the import instance version is lower - than that of the exporter. - - For existing installations, the project import option has to be enabled in - application settings (`/admin/application_settings`) under 'Import sources'. - Ask your administrator if you don't see the **GitLab export** button when - creating a new project. - - You can find some useful raketasks if you are an administrator in the - [import_export](../../../administration/raketasks/project_import_export.md) - raketask. - - The exports are stored in a temporary [shared directory][tmp] and are deleted - every 24 hours by a specific worker. +> +> - [Introduced][ce-3050] in GitLab 8.9. +> - Importing will not be possible if the import instance version is lower +> than that of the exporter. +> - For existing installations, the project import option has to be enabled in +> application settings (`/admin/application_settings`) under 'Import sources'. +> Ask your administrator if you don't see the **GitLab export** button when +> creating a new project. +> - You can find some useful raketasks if you are an administrator in the +> [import_export](../../../administration/raketasks/project_import_export.md) +> raketask. +> - The exports are stored in a temporary [shared directory][tmp] and are deleted +> every 24 hours by a specific worker. Existing projects running on any GitLab instance or GitLab.com can be exported with all their related data and be moved into a new GitLab instance. diff --git a/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md index 8559b67af04..d4b28d875cd 100644 --- a/doc/web_hooks/web_hooks.md +++ b/doc/web_hooks/web_hooks.md @@ -26,6 +26,10 @@ GitLab webhooks keep in mind the following things: you are writing a low-level hook this is important to remember. - GitLab ignores the HTTP status code returned by your endpoint. +## Secret Token + +If you specify a secret token, it will be sent with the hook request in the `X-Gitlab-Token` HTTP header. Your webhook endpoint can check that to verify that the request is legitimate. + ## SSL Verification By default, the SSL certificate of the webhook endpoint is verified based on diff --git a/doc/workflow/award_emoji.md b/doc/workflow/award_emoji.md index e6f8b792707..1df0698afd0 100644 --- a/doc/workflow/award_emoji.md +++ b/doc/workflow/award_emoji.md @@ -1,7 +1,7 @@ # Award emoji >**Note:** -This feature was [introduced][1825] in GitLab 8.2. +[Introduced][1825] in GitLab 8.2. When you're collaborating online, you get fewer opportunities for high-fives and thumbs-ups. Emoji can be awarded to issues and merge requests, making @@ -16,7 +16,7 @@ award emoji. ## Sort issues and merge requests on vote count >**Note:** -This feature was [introduced][2871] in GitLab 8.5. +[Introduced][2871] in GitLab 8.5. You can quickly sort issues and merge requests by the number of votes they have received. The sort options can be found in the dropdown menu as "Most @@ -45,7 +45,7 @@ downvotes. ## Award emoji for comments >**Note:** -This feature was [introduced][4291] in GitLab 8.9. +[Introduced][4291] in GitLab 8.9. Award emoji can also be applied to individual comments when you want to celebrate an accomplishment or agree with an opinion. diff --git a/doc/workflow/cherry_pick_changes.md b/doc/workflow/cherry_pick_changes.md index 4a499009842..64b94d81024 100644 --- a/doc/workflow/cherry_pick_changes.md +++ b/doc/workflow/cherry_pick_changes.md @@ -1,7 +1,6 @@ # Cherry-pick changes ->**Note:** -This feature was [introduced][ce-3514] in GitLab 8.7. +> [Introduced][ce-3514] in GitLab 8.7. --- diff --git a/doc/workflow/file_finder.md b/doc/workflow/file_finder.md index b69ae663272..8d87b030c83 100644 --- a/doc/workflow/file_finder.md +++ b/doc/workflow/file_finder.md @@ -1,6 +1,6 @@ # File finder -_**Note:** This feature was [introduced][gh-9889] in GitLab 8.4._ +> [Introduced][gh-9889] in GitLab 8.4. --- diff --git a/doc/workflow/revert_changes.md b/doc/workflow/revert_changes.md index 399366b0cdc..5ead9f4177f 100644 --- a/doc/workflow/revert_changes.md +++ b/doc/workflow/revert_changes.md @@ -1,6 +1,6 @@ # Reverting changes -_**Note:** This feature was [introduced][ce-1990] in GitLab 8.5._ +> [Introduced][ce-1990] in GitLab 8.5. --- diff --git a/doc/workflow/todos.md b/doc/workflow/todos.md index 9524ffd5420..a50ba305deb 100644 --- a/doc/workflow/todos.md +++ b/doc/workflow/todos.md @@ -1,6 +1,6 @@ # GitLab Todos ->**Note:** This feature was [introduced][ce-2817] in GitLab 8.5. +> [Introduced][ce-2817] in GitLab 8.5. When you log into GitLab, you normally want to see where you should spend your time and take some action, or what you need to keep an eye on. All without the diff --git a/doc/workflow/web_editor.md b/doc/workflow/web_editor.md index 1832567a34c..ee8e7862572 100644 --- a/doc/workflow/web_editor.md +++ b/doc/workflow/web_editor.md @@ -70,8 +70,7 @@ There are multiple ways to create a branch from GitLab's web interface. ### Create a new branch from an issue ->**Note:** -This feature was [introduced][ce-2808] in GitLab 8.6. +> [Introduced][ce-2808] in GitLab 8.6. In case your development workflow dictates to have an issue for every merge request, you can quickly create a branch right on the issue page which will be diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index 21768c15c17..6bac6011467 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -237,6 +237,15 @@ Feature: Project Merge Requests Then I should see additional file lines @javascript + Scenario: I unfold diff in Side-by-Side view + Given project "Shop" have "Bug NS-05" open merge request with diffs inside + And I visit merge request page "Bug NS-05" + And I click on the Changes tab + And I click Side-by-side Diff tab + And I unfold diff + Then I should see additional file lines + + @javascript Scenario: I show comments on a merge request side-by-side diff with comments in multiple files Given project "Shop" have "Bug NS-05" open merge request with diffs inside And I visit merge request page "Bug NS-05" diff --git a/features/steps/project/commits/branches.rb b/features/steps/project/commits/branches.rb index 0a42931147d..4bfb7e92e99 100644 --- a/features/steps/project/commits/branches.rb +++ b/features/steps/project/commits/branches.rb @@ -25,7 +25,7 @@ class Spinach::Features::ProjectCommitsBranches < Spinach::FeatureSteps step 'project "Shop" has protected branches' do project = Project.find_by(name: "Shop") - project.protected_branches.create(name: "stable") + create(:protected_branch, project: project, name: "stable") end step 'I click new branch link' do diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index da848afd48e..a02a54923a5 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -477,6 +477,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps step 'I click Side-by-side Diff tab' do find('a', text: 'Side-by-side').trigger('click') + + # Waits for load + expect(page).to have_css('.parallel') end step 'I should see comments on the side-by-side diff page' do diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 0fe046dcbf6..9a8896acb15 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -293,7 +293,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps first('.js-project-refs-dropdown').click page.within '.project-refs-form' do - click_link 'test' + click_link "'test'" end end diff --git a/features/steps/project/wiki.rb b/features/steps/project/wiki.rb index 5ad82a9b3c1..732dc5d0b93 100644 --- a/features/steps/project/wiki.rb +++ b/features/steps/project/wiki.rb @@ -130,15 +130,15 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps step 'I create a New page with paths' do click_on 'New Page' - fill_in 'Page slug', with: 'one/two/three' + fill_in 'Page slug', with: 'one/two/three-test' click_on 'Create Page' fill_in "wiki_content", with: 'wiki content' click_on "Create page" - expect(current_path).to include 'one/two/three' + expect(current_path).to include 'one/two/three-test' end step 'I should see non-escaped link in the pages list' do - expect(page).to have_xpath("//a[@href='/#{project.path_with_namespace}/wikis/one/two/three']") + expect(page).to have_xpath("//a[@href='/#{project.path_with_namespace}/wikis/one/two/three-test']") end step 'I edit the Wiki page with a path' do @@ -147,7 +147,7 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps end step 'I should see a non-escaped path' do - expect(current_path).to include 'one/two/three' + expect(current_path).to include 'one/two/three-test' end step 'I should see the Editing page' do diff --git a/features/support/env.rb b/features/support/env.rb index f0a3dd8d2d0..569fd444e86 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -1,6 +1,5 @@ -if ENV['SIMPLECOV'] - require 'simplecov' -end +require './spec/simplecov_env' +SimpleCovEnv.start! ENV['RAILS_ENV'] = 'test' require './config/environment' diff --git a/lib/api/api.rb b/lib/api/api.rb index 3d7d67510a8..bd16806892b 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -7,6 +7,10 @@ module API rack_response({ 'message' => '404 Not found' }.to_json, 404) end + rescue_from Grape::Exceptions::ValidationErrors do |e| + error!({ messages: e.full_messages }, 400) + end + rescue_from :all do |exception| # lifted from https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb#L60 # why is this not wrapped in something reusable? @@ -32,6 +36,7 @@ module API mount ::API::CommitStatuses mount ::API::Commits mount ::API::DeployKeys + mount ::API::Environments mount ::API::Files mount ::API::GroupMembers mount ::API::Groups diff --git a/lib/api/branches.rb b/lib/api/branches.rb index 66b853eb342..a77afe634f6 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -35,6 +35,10 @@ module API # Protect a single branch # + # Note: The internal data model moved from `developers_can_{merge,push}` to `allowed_to_{merge,push}` + # in `gitlab-org/gitlab-ce!5081`. The API interface has not been changed (to maintain compatibility), + # but it works with the changed data model to infer `developers_can_merge` and `developers_can_push`. + # # Parameters: # id (required) - The ID of a project # branch (required) - The name of the branch @@ -49,17 +53,36 @@ module API @branch = user_project.repository.find_branch(params[:branch]) not_found!('Branch') unless @branch protected_branch = user_project.protected_branches.find_by(name: @branch.name) - developers_can_push = to_boolean(params[:developers_can_push]) + developers_can_merge = to_boolean(params[:developers_can_merge]) + developers_can_push = to_boolean(params[:developers_can_push]) + + protected_branch_params = { + name: @branch.name + } + + unless developers_can_merge.nil? + protected_branch_params.merge!({ + merge_access_level_attributes: { + access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + } + }) + end + + unless developers_can_push.nil? + protected_branch_params.merge!({ + push_access_level_attributes: { + access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + } + }) + end if protected_branch - protected_branch.developers_can_push = developers_can_push unless developers_can_push.nil? - protected_branch.developers_can_merge = developers_can_merge unless developers_can_merge.nil? - protected_branch.save + service = ProtectedBranches::UpdateService.new(user_project, current_user, protected_branch_params) + service.execute(protected_branch) else - user_project.protected_branches.create(name: @branch.name, - developers_can_push: developers_can_push || false, - developers_can_merge: developers_can_merge || false) + service = ProtectedBranches::CreateService.new(user_project, current_user, protected_branch_params) + service.execute end present @branch, with: Entities::RepoBranch, project: user_project diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 4a11c8e3620..b4eaf1813d4 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -54,7 +54,7 @@ module API sha = params[:sha] commit = user_project.commit(sha) not_found! "Commit" unless commit - commit.diffs.to_a + commit.raw_diffs.to_a end # Get a commit's comments @@ -96,7 +96,7 @@ module API } if params[:path] && params[:line] && params[:line_type] - commit.diffs(all_diffs: true).each do |diff| + commit.raw_diffs(all_diffs: true).each do |diff| next unless diff.new_path == params[:path] lines = Gitlab::Diff::Parser.new.parse(diff.diff.each_line) diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb index 5c570b5e5ca..825e05fbae3 100644 --- a/lib/api/deploy_keys.rb +++ b/lib/api/deploy_keys.rb @@ -10,6 +10,9 @@ module API present keys, with: Entities::SSHKey end + params do + requires :id, type: String, desc: 'The ID of the project' + end resource :projects do before { authorize_admin_project } @@ -17,52 +20,43 @@ module API # Use "projects/:id/deploy_keys/..." instead. # %w(keys deploy_keys).each do |path| - # Get a specific project's deploy keys - # - # Example Request: - # GET /projects/:id/deploy_keys + desc "Get a specific project's deploy keys" do + success Entities::SSHKey + end get ":id/#{path}" do present user_project.deploy_keys, with: Entities::SSHKey end - # Get single deploy key owned by currently authenticated user - # - # Example Request: - # GET /projects/:id/deploy_keys/:key_id + desc 'Get single deploy key' do + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + end get ":id/#{path}/:key_id" do key = user_project.deploy_keys.find params[:key_id] present key, with: Entities::SSHKey end - # Add new deploy key to currently authenticated user - # If deploy key already exists - it will be joined to project - # but only if original one was accessible by same user - # - # Parameters: - # key (required) - New deploy Key - # title (required) - New deploy Key's title - # Example Request: - # POST /projects/:id/deploy_keys + # TODO: for 9.0 we should check if params are there with the params block + # grape provides, at this point we'd change behaviour so we can't + # Behaviour now if you don't provide all required params: it renders a + # validation error or two. + desc 'Add new deploy key to currently authenticated user' do + success Entities::SSHKey + end post ":id/#{path}" do attrs = attributes_for_keys [:title, :key] + attrs[:key].strip! if attrs[:key] - if attrs[:key].present? - attrs[:key].strip! - - # check if key already exist in project - key = user_project.deploy_keys.find_by(key: attrs[:key]) - if key - present key, with: Entities::SSHKey - next - end + key = user_project.deploy_keys.find_by(key: attrs[:key]) + present key, with: Entities::SSHKey if key - # Check for available deploy keys in other projects - key = current_user.accessible_deploy_keys.find_by(key: attrs[:key]) - if key - user_project.deploy_keys << key - present key, with: Entities::SSHKey - next - end + # Check for available deploy keys in other projects + key = current_user.accessible_deploy_keys.find_by(key: attrs[:key]) + if key + user_project.deploy_keys << key + present key, with: Entities::SSHKey end key = DeployKey.new attrs @@ -74,12 +68,46 @@ module API end end - # Delete existing deploy key of currently authenticated user - # - # Example Request: - # DELETE /projects/:id/deploy_keys/:key_id + desc 'Enable a deploy key for a project' do + detail 'This feature was added in GitLab 8.11' + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + end + post ":id/#{path}/:key_id/enable" do + key = ::Projects::EnableDeployKeyService.new(user_project, + current_user, declared(params)).execute + + if key + present key, with: Entities::SSHKey + else + not_found!('Deploy Key') + end + end + + desc 'Disable a deploy key for a project' do + detail 'This feature was added in GitLab 8.11' + success Entities::SSHKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + end + delete ":id/#{path}/:key_id/disable" do + key = user_project.deploy_keys_projects.find_by(deploy_key_id: params[:key_id]) + key.destroy + + present key.deploy_key, with: Entities::SSHKey + end + + desc 'Delete existing deploy key of currently authenticated user' do + success Key + end + params do + requires :key_id, type: Integer, desc: 'The ID of the deploy key' + end delete ":id/#{path}/:key_id" do - key = user_project.deploy_keys.find params[:key_id] + key = user_project.deploy_keys.find(params[:key_id]) key.destroy end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index fbf0d74663f..e5b00dc45a5 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -126,11 +126,13 @@ module API end expose :developers_can_push do |repo_branch, options| - options[:project].developers_can_push_to_protected_branch? repo_branch.name + project = options[:project] + project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.push_access_level.access_level == Gitlab::Access::DEVELOPER } end expose :developers_can_merge do |repo_branch, options| - options[:project].developers_can_merge_to_protected_branch? repo_branch.name + project = options[:project] + project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.merge_access_level.access_level == Gitlab::Access::DEVELOPER } end end @@ -149,8 +151,13 @@ module API expose :safe_message, as: :message end + class RepoCommitStats < Grape::Entity + expose :additions, :deletions, :total + end + class RepoCommitDetail < RepoCommit expose :parent_ids, :committed_date, :authored_date + expose :stats, using: Entities::RepoCommitStats expose :status end @@ -217,7 +224,7 @@ module API class MergeRequestChanges < MergeRequest expose :diffs, as: :changes, using: Entities::RepoDiff do |compare, _| - compare.diffs(all_diffs: true).to_a + compare.raw_diffs(all_diffs: true).to_a end end @@ -489,6 +496,10 @@ module API expose :key, :value end + class Environment < Grape::Entity + expose :id, :name, :external_url + end + class RepoLicense < Grape::Entity expose :key, :name, :nickname expose :featured, as: :popular diff --git a/lib/api/environments.rb b/lib/api/environments.rb new file mode 100644 index 00000000000..819f80d8365 --- /dev/null +++ b/lib/api/environments.rb @@ -0,0 +1,83 @@ +module API + # Environments RESTfull API endpoints + class Environments < Grape::API + before { authenticate! } + + params do + requires :id, type: String, desc: 'The project ID' + end + resource :projects do + desc 'Get all environments of the project' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Environment + end + params do + optional :page, type: Integer, desc: 'Page number of the current request' + optional :per_page, type: Integer, desc: 'Number of items per page' + end + get ':id/environments' do + authorize! :read_environment, user_project + + present paginate(user_project.environments), with: Entities::Environment + end + + desc 'Creates a new environment' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Environment + end + params do + requires :name, type: String, desc: 'The name of the environment to be created' + optional :external_url, type: String, desc: 'URL on which this deployment is viewable' + end + post ':id/environments' do + authorize! :create_environment, user_project + + create_params = declared(params, include_parent_namespaces: false).to_h + environment = user_project.environments.create(create_params) + + if environment.persisted? + present environment, with: Entities::Environment + else + render_validation_error!(environment) + end + end + + desc 'Updates an existing environment' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Environment + end + params do + requires :environment_id, type: Integer, desc: 'The environment ID' + optional :name, type: String, desc: 'The new environment name' + optional :external_url, type: String, desc: 'The new URL on which this deployment is viewable' + end + put ':id/environments/:environment_id' do + authorize! :update_environment, user_project + + environment = user_project.environments.find(params[:environment_id]) + + update_params = declared(params, include_missing: false).extract!(:name, :external_url).to_h + if environment.update(update_params) + present environment, with: Entities::Environment + else + render_validation_error!(environment) + end + end + + desc 'Deletes an existing environment' do + detail 'This feature was introduced in GitLab 8.11.' + success Entities::Environment + end + params do + requires :environment_id, type: Integer, desc: 'The environment ID' + end + delete ':id/environments/:environment_id' do + authorize! :update_environment, user_project + + environment = user_project.environments.find(params[:environment_id]) + + present environment.destroy, with: Entities::Environment + end + end + end +end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index c588103e517..c4d3134da6c 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -21,17 +21,6 @@ module API def filter_issues_milestone(issues, milestone) issues.includes(:milestone).where('milestones.title' => milestone) end - - def create_spam_log(project, current_user, attrs) - params = attrs.merge({ - source_ip: client_ip(env), - user_agent: user_agent(env), - noteable_type: 'Issue', - via_api: true - }) - - ::CreateSpamLogService.new(project, current_user, params).execute - end end resource :issues do @@ -168,15 +157,13 @@ module API end project = user_project - text = [attrs[:title], attrs[:description]].reject(&:blank?).join("\n") - if check_for_spam?(project, current_user) && is_spam?(env, current_user, text) - create_spam_log(project, current_user, attrs) + issue = ::Issues::CreateService.new(project, current_user, attrs.merge(request: request, api: true)).execute + + if issue.spam? render_api_error!({ error: 'Spam detected' }, 400) end - issue = ::Issues::CreateService.new(project, current_user, attrs).execute - if issue.valid? # Find or create labels and attach to issue. Labels are valid because # we already checked its name, so there can't be an error here diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index b9773f98d75..1f5917b8127 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -54,10 +54,10 @@ module Backup # Move repos dir to 'repositories.old' dir bk_repos_path = File.join(path, '..', 'repositories.old.' + Time.now.to_i.to_s) FileUtils.mv(path, bk_repos_path) + # This is expected from gitlab:check + FileUtils.mkdir_p(path, mode: 2770) end - FileUtils.mkdir_p(repos_path) - Project.find_each(batch_size: 1000) do |project| $progress.print " * #{project.path_with_namespace} ... " diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb index 9ed45707515..799b83b1069 100644 --- a/lib/banzai/filter/autolink_filter.rb +++ b/lib/banzai/filter/autolink_filter.rb @@ -31,6 +31,14 @@ module Banzai # Text matching LINK_PATTERN inside these elements will not be linked IGNORE_PARENTS = %w(a code kbd pre script style).to_set + # The XPath query to use for finding text nodes to parse. + TEXT_QUERY = %Q(descendant-or-self::text()[ + not(#{IGNORE_PARENTS.map { |p| "ancestor::#{p}" }.join(' or ')}) + and contains(., '://') + and not(starts-with(., 'http')) + and not(starts-with(., 'ftp')) + ]) + def call return doc if context[:autolink] == false @@ -66,16 +74,11 @@ module Banzai # Autolinks any text matching LINK_PATTERN that Rinku didn't already # replace def text_parse - search_text_nodes(doc).each do |node| + doc.xpath(TEXT_QUERY).each do |node| content = node.to_html - next if has_ancestor?(node, IGNORE_PARENTS) next unless content.match(LINK_PATTERN) - # If Rinku didn't link this, there's probably a good reason, so we'll - # skip it too - next if content.start_with?(*%w(http https ftp)) - html = autolink_filter(content) next if html == content diff --git a/lib/banzai/filter/emoji_filter.rb b/lib/banzai/filter/emoji_filter.rb index ae7d31cf191..2492b5213ac 100644 --- a/lib/banzai/filter/emoji_filter.rb +++ b/lib/banzai/filter/emoji_filter.rb @@ -38,6 +38,11 @@ module Banzai end end + # Build a regexp that matches all valid :emoji: names. + def self.emoji_pattern + @emoji_pattern ||= /:(#{Gitlab::Emoji.emojis_names.map { |name| Regexp.escape(name) }.join('|')}):/ + end + private def emoji_url(name) @@ -59,11 +64,6 @@ module Banzai ActionController::Base.helpers.url_to_image(image) end - # Build a regexp that matches all valid :emoji: names. - def self.emoji_pattern - @emoji_pattern ||= /:(#{Gitlab::Emoji.emojis_names.map { |name| Regexp.escape(name) }.join('|')}):/ - end - def emoji_pattern self.class.emoji_pattern end diff --git a/lib/banzai/filter/markdown_filter.rb b/lib/banzai/filter/markdown_filter.rb index 9b209533a89..ff580ec68f8 100644 --- a/lib/banzai/filter/markdown_filter.rb +++ b/lib/banzai/filter/markdown_filter.rb @@ -12,7 +12,12 @@ module Banzai html end - private + def self.renderer + @renderer ||= begin + renderer = Redcarpet::Render::HTML.new + Redcarpet::Markdown.new(renderer, redcarpet_options) + end + end def self.redcarpet_options # https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use @@ -28,12 +33,7 @@ module Banzai }.freeze end - def self.renderer - @renderer ||= begin - renderer = Redcarpet::Render::HTML.new - Redcarpet::Markdown.new(renderer, redcarpet_options) - end - end + private_class_method :redcarpet_options end end end diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/relative_link_filter.rb index 21ed0410f7f..46762d401fb 100644 --- a/lib/banzai/filter/relative_link_filter.rb +++ b/lib/banzai/filter/relative_link_filter.rb @@ -20,7 +20,7 @@ module Banzai process_link_attr el.attribute('href') end - doc.search('img').each do |el| + doc.css('img, video').each do |el| process_link_attr el.attribute('src') end @@ -35,6 +35,7 @@ module Banzai def process_link_attr(html_attr) return if html_attr.blank? + return if html_attr.value.start_with?('//') uri = URI(html_attr.value) if uri.relative? && uri.path.present? @@ -87,10 +88,13 @@ module Banzai def build_relative_path(path, request_path) return request_path if path.empty? return path unless request_path + return path[1..-1] if path.start_with?('/') parts = request_path.split('/') parts.pop if uri_type(request_path) != :tree + path.sub!(%r{\A\./}, '') + while path.start_with?('../') parts.pop path.sub!('../', '') diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index 91f0159f9a1..fcdb496aed2 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -17,15 +17,12 @@ module Banzai def highlight_node(node) language = node.attr('class') - code = node.text - + code = node.text css_classes = "code highlight" - - lexer = Rouge::Lexer.find_fancy(language) || Rouge::Lexers::PlainText - formatter = Rouge::Formatters::HTML.new + lexer = lexer_for(language) begin - code = formatter.format(lexer.lex(code)) + code = format(lex(lexer, code)) css_classes << " js-syntax-highlight #{lexer.tag}" rescue @@ -41,14 +38,27 @@ module Banzai private + # Separate method so it can be instrumented. + def lex(lexer, code) + lexer.lex(code) + end + + def format(tokens) + rouge_formatter.format(tokens) + end + + def lexer_for(language) + (Rouge::Lexer.find(language) || Rouge::Lexers::PlainText).new + end + def replace_parent_pre_element(node, highlighted) # Replace the parent `pre` element with the entire highlighted block node.parent.replace(highlighted) end # Override Rouge::Plugins::Redcarpet#rouge_formatter - def rouge_formatter(lexer) - Rouge::Formatters::HTML.new + def rouge_formatter(lexer = nil) + @rouge_formatter ||= Rouge::Formatters::HTML.new end end end diff --git a/lib/banzai/filter/video_link_filter.rb b/lib/banzai/filter/video_link_filter.rb index fd8b9a6f0cc..ac7bbcb0d10 100644 --- a/lib/banzai/filter/video_link_filter.rb +++ b/lib/banzai/filter/video_link_filter.rb @@ -1,11 +1,9 @@ module Banzai module Filter - # Find every image that isn't already wrapped in an `a` tag, and that has # a `src` attribute ending with a video extension, add a new video node and # a "Download" link in the case the video cannot be played. class VideoLinkFilter < HTML::Pipeline::Filter - def call doc.xpath(query).each do |el| el.replace(video_node(doc, el)) @@ -54,6 +52,5 @@ module Banzai container end end - end end diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index f306079d833..6c20dec5734 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -9,10 +9,11 @@ module Banzai issues = issues_for_nodes(nodes) - nodes.select do |node| - issue = issue_for_node(issues, node) + readable_issues = Ability. + issues_readable_by_user(issues.values, user).to_set - issue ? can?(user, :read_issue, issue) : false + nodes.select do |node| + readable_issues.include?(issue_for_node(issues, node)) end end diff --git a/lib/banzai/renderer.rb b/lib/banzai/renderer.rb index 910687a7b6a..a4ae27eefd8 100644 --- a/lib/banzai/renderer.rb +++ b/lib/banzai/renderer.rb @@ -1,5 +1,7 @@ module Banzai module Renderer + extend self + # Convert a Markdown String into an HTML-safe String of HTML # # Note that while the returned HTML will have been sanitized of dangerous @@ -14,7 +16,7 @@ module Banzai # context - Hash of context options passed to our HTML Pipeline # # Returns an HTML-safe String - def self.render(text, context = {}) + def render(text, context = {}) cache_key = context.delete(:cache_key) cache_key = full_cache_key(cache_key, context[:pipeline]) @@ -52,7 +54,7 @@ module Banzai # texts_and_contexts # => [{ text: '### Hello', # context: { cache_key: [note, :note] } }] - def self.cache_collection_render(texts_and_contexts) + def cache_collection_render(texts_and_contexts) items_collection = texts_and_contexts.each_with_index do |item, index| context = item[:context] cache_key = full_cache_multi_key(context.delete(:cache_key), context[:pipeline]) @@ -81,7 +83,7 @@ module Banzai items_collection.map { |item| item[:rendered] } end - def self.render_result(text, context = {}) + def render_result(text, context = {}) text = Pipeline[:pre_process].to_html(text, context) if text Pipeline[context[:pipeline]].call(text, context) @@ -100,7 +102,7 @@ module Banzai # :user - User object # # Returns an HTML-safe String - def self.post_process(html, context) + def post_process(html, context) context = Pipeline[context[:pipeline]].transform_context(context) pipeline = Pipeline[:post_process] @@ -113,7 +115,7 @@ module Banzai private - def self.cacheless_render(text, context = {}) + def cacheless_render(text, context = {}) Gitlab::Metrics.measure(:banzai_cacheless_render) do result = render_result(text, context) @@ -126,7 +128,7 @@ module Banzai end end - def self.full_cache_key(cache_key, pipeline_name) + def full_cache_key(cache_key, pipeline_name) return unless cache_key ["banzai", *cache_key, pipeline_name || :full] end @@ -134,7 +136,7 @@ module Banzai # To map Rails.cache.read_multi results we need to know the Rails.cache.expanded_key. # Other option will be to generate stringified keys on our side and don't delegate to Rails.cache.expanded_key # method. - def self.full_cache_multi_key(cache_key, pipeline_name) + def full_cache_multi_key(cache_key, pipeline_name) return unless cache_key Rails.cache.send(:expanded_key, full_cache_key(cache_key, pipeline_name)) end diff --git a/lib/ci/charts.rb b/lib/ci/charts.rb index 1d7126a432d..3decc3b1a26 100644 --- a/lib/ci/charts.rb +++ b/lib/ci/charts.rb @@ -1,5 +1,37 @@ module Ci module Charts + module DailyInterval + def grouped_count(query) + query. + group("DATE(#{Ci::Build.table_name}.created_at)"). + count(:created_at). + transform_keys { |date| date.strftime(@format) } + end + + def interval_step + @interval_step ||= 1.day + end + end + + module MonthlyInterval + def grouped_count(query) + if Gitlab::Database.postgresql? + query. + group("to_char(#{Ci::Build.table_name}.created_at, '01 Month YYYY')"). + count(:created_at). + transform_keys(&:squish) + else + query. + group("DATE_FORMAT(#{Ci::Build.table_name}.created_at, '01 %M %Y')"). + count(:created_at) + end + end + + def interval_step + @interval_step ||= 1.month + end + end + class Chart attr_reader :labels, :total, :success, :project, :build_times @@ -13,47 +45,59 @@ module Ci collect end - def push(from, to, format) - @labels << from.strftime(format) - @total << project.builds. - where("? > #{Ci::Build.table_name}.created_at AND #{Ci::Build.table_name}.created_at > ?", to, from). - count(:all) - @success << project.builds. - where("? > #{Ci::Build.table_name}.created_at AND #{Ci::Build.table_name}.created_at > ?", to, from). - success.count(:all) + def collect + query = project.builds. + where("? > #{Ci::Build.table_name}.created_at AND #{Ci::Build.table_name}.created_at > ?", @to, @from) + + totals_count = grouped_count(query) + success_count = grouped_count(query.success) + + current = @from + while current < @to + label = current.strftime(@format) + + @labels << label + @total << (totals_count[label] || 0) + @success << (success_count[label] || 0) + + current += interval_step + end end end class YearChart < Chart - def collect - 13.times do |i| - start_month = (Date.today.years_ago(1) + i.month).beginning_of_month - end_month = start_month.end_of_month + include MonthlyInterval - push(start_month, end_month, "%d %B %Y") - end + def initialize(*) + @to = Date.today.end_of_month + @from = @to.years_ago(1).beginning_of_month + @format = '%d %B %Y' + + super end end class MonthChart < Chart - def collect - 30.times do |i| - start_day = Date.today - 30.days + i.days - end_day = Date.today - 30.days + i.day + 1.day + include DailyInterval - push(start_day, end_day, "%d %B") - end + def initialize(*) + @to = Date.today + @from = @to - 30.days + @format = '%d %B' + + super end end class WeekChart < Chart - def collect - 7.times do |i| - start_day = Date.today - 7.days + i.days - end_day = Date.today - 7.days + i.day + 1.day + include DailyInterval - push(start_day, end_day, "%d %B") - end + def initialize(*) + @to = Date.today + @from = @to - 7.days + @format = '%d %B' + + super end end diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 83afed9f49f..a2e8bd22a52 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -4,21 +4,11 @@ module Ci include Gitlab::Ci::Config::Node::LegacyValidationHelpers - DEFAULT_STAGE = 'test' - ALLOWED_YAML_KEYS = [:before_script, :after_script, :image, :services, :types, :stages, :variables, :cache] - ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, - :allow_failure, :type, :stage, :when, :artifacts, :cache, - :dependencies, :before_script, :after_script, :variables, - :environment] - ALLOWED_CACHE_KEYS = [:key, :untracked, :paths] - ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when, :expire_in] - attr_reader :path, :cache, :stages def initialize(config, path = nil) @ci_config = Gitlab::Ci::Config.new(config) @config = @ci_config.to_hash - @path = path unless @ci_config.valid? @@ -26,7 +16,6 @@ module Ci end initial_parsing - validate! rescue Gitlab::Ci::Config::Loader::FormatError => e raise ValidationError, e.message end @@ -73,7 +62,7 @@ module Ci # - before script should be a concatenated command commands: [job[:before_script] || @before_script, job[:script]].flatten.compact.join("\n"), tag_list: job[:tags] || [], - name: name, + name: job[:name], allow_failure: job[:allow_failure] || false, when: job[:when] || 'on_success', environment: job[:environment], @@ -92,6 +81,9 @@ module Ci private def initial_parsing + ## + # Global config + # @before_script = @ci_config.before_script @image = @ci_config.image @after_script = @ci_config.after_script @@ -100,34 +92,28 @@ module Ci @stages = @ci_config.stages @cache = @ci_config.cache - @jobs = {} - - @config.except!(*ALLOWED_YAML_KEYS) - @config.each { |name, param| add_job(name, param) } - - raise ValidationError, "Please define at least one job" if @jobs.none? - end - - def add_job(name, job) - return if name.to_s.start_with?('.') + ## + # Jobs + # + @jobs = @ci_config.jobs - raise ValidationError, "Unknown parameter: #{name}" unless job.is_a?(Hash) && job.has_key?(:script) + @jobs.each do |name, job| + # logical validation for job - stage = job[:stage] || job[:type] || DEFAULT_STAGE - @jobs[name] = { stage: stage }.merge(job) + validate_job_stage!(name, job) + validate_job_dependencies!(name, job) + end end def yaml_variables(name) - variables = global_variables.merge(job_variables(name)) + variables = (@variables || {}) + .merge(job_variables(name)) + variables.map do |key, value| { key: key, value: value, public: true } end end - def global_variables - @variables || {} - end - def job_variables(name) job = @jobs[name.to_sym] return {} unless job @@ -135,154 +121,16 @@ module Ci job[:variables] || {} end - def validate! - @jobs.each do |name, job| - validate_job!(name, job) - end - - true - end - - def validate_job!(name, job) - validate_job_name!(name) - validate_job_keys!(name, job) - validate_job_types!(name, job) - validate_job_script!(name, job) - - validate_job_stage!(name, job) if job[:stage] - validate_job_variables!(name, job) if job[:variables] - validate_job_cache!(name, job) if job[:cache] - validate_job_artifacts!(name, job) if job[:artifacts] - validate_job_dependencies!(name, job) if job[:dependencies] - end - - def validate_job_name!(name) - if name.blank? || !validate_string(name) - raise ValidationError, "job name should be non-empty string" - end - end - - def validate_job_keys!(name, job) - job.keys.each do |key| - unless ALLOWED_JOB_KEYS.include? key - raise ValidationError, "#{name} job: unknown parameter #{key}" - end - end - end - - def validate_job_types!(name, job) - if job[:image] && !validate_string(job[:image]) - raise ValidationError, "#{name} job: image should be a string" - end - - if job[:services] && !validate_array_of_strings(job[:services]) - raise ValidationError, "#{name} job: services should be an array of strings" - end - - if job[:tags] && !validate_array_of_strings(job[:tags]) - raise ValidationError, "#{name} job: tags parameter should be an array of strings" - end - - if job[:only] && !validate_array_of_strings_or_regexps(job[:only]) - raise ValidationError, "#{name} job: only parameter should be an array of strings or regexps" - end - - if job[:except] && !validate_array_of_strings_or_regexps(job[:except]) - raise ValidationError, "#{name} job: except parameter should be an array of strings or regexps" - end - - if job[:allow_failure] && !validate_boolean(job[:allow_failure]) - raise ValidationError, "#{name} job: allow_failure parameter should be an boolean" - end - - if job[:when] && !job[:when].in?(%w[on_success on_failure always manual]) - raise ValidationError, "#{name} job: when parameter should be on_success, on_failure, always or manual" - end - - if job[:environment] && !validate_environment(job[:environment]) - raise ValidationError, "#{name} job: environment parameter #{Gitlab::Regex.environment_name_regex_message}" - end - end - - def validate_job_script!(name, job) - if !validate_string(job[:script]) && !validate_array_of_strings(job[:script]) - raise ValidationError, "#{name} job: script should be a string or an array of a strings" - end - - if job[:before_script] && !validate_array_of_strings(job[:before_script]) - raise ValidationError, "#{name} job: before_script should be an array of strings" - end - - if job[:after_script] && !validate_array_of_strings(job[:after_script]) - raise ValidationError, "#{name} job: after_script should be an array of strings" - end - end - def validate_job_stage!(name, job) + return unless job[:stage] + unless job[:stage].is_a?(String) && job[:stage].in?(@stages) raise ValidationError, "#{name} job: stage parameter should be #{@stages.join(", ")}" end end - def validate_job_variables!(name, job) - unless validate_variables(job[:variables]) - raise ValidationError, - "#{name} job: variables should be a map of key-value strings" - end - end - - def validate_job_cache!(name, job) - job[:cache].keys.each do |key| - unless ALLOWED_CACHE_KEYS.include? key - raise ValidationError, "#{name} job: cache unknown parameter #{key}" - end - end - - if job[:cache][:key] && !validate_string(job[:cache][:key]) - raise ValidationError, "#{name} job: cache:key parameter should be a string" - end - - if job[:cache][:untracked] && !validate_boolean(job[:cache][:untracked]) - raise ValidationError, "#{name} job: cache:untracked parameter should be an boolean" - end - - if job[:cache][:paths] && !validate_array_of_strings(job[:cache][:paths]) - raise ValidationError, "#{name} job: cache:paths parameter should be an array of strings" - end - end - - def validate_job_artifacts!(name, job) - job[:artifacts].keys.each do |key| - unless ALLOWED_ARTIFACTS_KEYS.include? key - raise ValidationError, "#{name} job: artifacts unknown parameter #{key}" - end - end - - if job[:artifacts][:name] && !validate_string(job[:artifacts][:name]) - raise ValidationError, "#{name} job: artifacts:name parameter should be a string" - end - - if job[:artifacts][:untracked] && !validate_boolean(job[:artifacts][:untracked]) - raise ValidationError, "#{name} job: artifacts:untracked parameter should be an boolean" - end - - if job[:artifacts][:paths] && !validate_array_of_strings(job[:artifacts][:paths]) - raise ValidationError, "#{name} job: artifacts:paths parameter should be an array of strings" - end - - if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w[on_success on_failure always]) - raise ValidationError, "#{name} job: artifacts:when parameter should be on_success, on_failure or always" - end - - if job[:artifacts][:expire_in] && !validate_duration(job[:artifacts][:expire_in]) - raise ValidationError, "#{name} job: artifacts:expire_in parameter should be a duration" - end - end - def validate_job_dependencies!(name, job) - unless validate_array_of_strings(job[:dependencies]) - raise ValidationError, "#{name} job: dependencies parameter should be an array of strings" - end + return unless job[:dependencies] stage_index = @stages.index(job[:stage]) diff --git a/lib/ci/static_model.rb b/lib/ci/static_model.rb deleted file mode 100644 index bb2bdbed495..00000000000 --- a/lib/ci/static_model.rb +++ /dev/null @@ -1,49 +0,0 @@ -# Provides an ActiveRecord-like interface to a model whose data is not persisted to a database. -module Ci - module StaticModel - extend ActiveSupport::Concern - - module ClassMethods - # Used by ActiveRecord's polymorphic association to set object_id - def primary_key - 'id' - end - - # Used by ActiveRecord's polymorphic association to set object_type - def base_class - self - end - end - - # Used by AR for fetching attributes - # - # Pass it along if we respond to it. - def [](key) - send(key) if respond_to?(key) - end - - def to_param - id - end - - def new_record? - false - end - - def persisted? - false - end - - def destroyed? - false - end - - def ==(other) - if other.is_a? ::Ci::StaticModel - id == other.id - else - super - end - end - end -end diff --git a/lib/gitlab/access.rb b/lib/gitlab/access.rb index de41ea415a6..a533bac2692 100644 --- a/lib/gitlab/access.rb +++ b/lib/gitlab/access.rb @@ -7,6 +7,7 @@ module Gitlab module Access class AccessDeniedError < StandardError; end + NO_ACCESS = 0 GUEST = 10 REPORTER = 20 DEVELOPER = 30 diff --git a/lib/gitlab/akismet_helper.rb b/lib/gitlab/akismet_helper.rb index 04676fdb748..207736b59db 100644 --- a/lib/gitlab/akismet_helper.rb +++ b/lib/gitlab/akismet_helper.rb @@ -17,8 +17,8 @@ module Gitlab env['HTTP_USER_AGENT'] end - def check_for_spam?(project, user) - akismet_enabled? && !project.team.member?(user) + def check_for_spam?(project) + akismet_enabled? && project.public? end def is_spam?(environment, user, text) diff --git a/lib/gitlab/backend/shell.rb b/lib/gitlab/backend/shell.rb index 34e0143a82e..839a4fa30d5 100644 --- a/lib/gitlab/backend/shell.rb +++ b/lib/gitlab/backend/shell.rb @@ -60,16 +60,18 @@ module Gitlab end # Fork repository to new namespace - # storage - project's storage path + # forked_from_storage - forked-from project's storage path # path - project path with namespace + # forked_to_storage - forked-to project's storage path # fork_namespace - namespace for forked project # # Ex. - # fork_repository("/path/to/storage", "gitlab/gitlab-ci", "randx") + # fork_repository("/path/to/forked_from/storage", "gitlab/gitlab-ci", "/path/to/forked_to/storage", "randx") # - def fork_repository(storage, path, fork_namespace) + def fork_repository(forked_from_storage, path, forked_to_storage, fork_namespace) Gitlab::Utils.system_silent([gitlab_shell_projects_path, 'fork-project', - storage, "#{path}.git", fork_namespace]) + forked_from_storage, "#{path}.git", forked_to_storage, + fork_namespace]) end # Remove repository from file system diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index e6cc1529760..ae82c0db3f1 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -8,7 +8,7 @@ module Gitlab # Temporary delegations that should be removed after refactoring # delegate :before_script, :image, :services, :after_script, :variables, - :stages, :cache, to: :@global + :stages, :cache, :jobs, to: :@global def initialize(config) @config = Loader.new(config).load! diff --git a/lib/gitlab/ci/config/node/artifacts.rb b/lib/gitlab/ci/config/node/artifacts.rb new file mode 100644 index 00000000000..844bd2fe998 --- /dev/null +++ b/lib/gitlab/ci/config/node/artifacts.rb @@ -0,0 +1,35 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents a configuration of job artifacts. + # + class Artifacts < Entry + include Validatable + include Attributable + + ALLOWED_KEYS = %i[name untracked paths when expire_in] + + attributes ALLOWED_KEYS + + validations do + validates :config, type: Hash + validates :config, allowed_keys: ALLOWED_KEYS + + with_options allow_nil: true do + validates :name, type: String + validates :untracked, boolean: true + validates :paths, array_of_strings: true + validates :when, + inclusion: { in: %w[on_success on_failure always], + message: 'should be on_success, on_failure ' \ + 'or always' } + validates :expire_in, duration: true + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/attributable.rb b/lib/gitlab/ci/config/node/attributable.rb new file mode 100644 index 00000000000..221b666f9f6 --- /dev/null +++ b/lib/gitlab/ci/config/node/attributable.rb @@ -0,0 +1,23 @@ +module Gitlab + module Ci + class Config + module Node + module Attributable + extend ActiveSupport::Concern + + class_methods do + def attributes(*attributes) + attributes.flatten.each do |attribute| + define_method(attribute) do + return unless config.is_a?(Hash) + + config[attribute] + end + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/cache.rb b/lib/gitlab/ci/config/node/cache.rb index cdf8ba2e35d..b4bda2841ac 100644 --- a/lib/gitlab/ci/config/node/cache.rb +++ b/lib/gitlab/ci/config/node/cache.rb @@ -8,6 +8,12 @@ module Gitlab class Cache < Entry include Configurable + ALLOWED_KEYS = %i[key untracked paths] + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + end + node :key, Node::Key, description: 'Cache key used to define a cache affinity.' @@ -16,10 +22,6 @@ module Gitlab node :paths, Node::Paths, description: 'Specify which paths should be cached across builds.' - - validations do - validates :config, allowed_keys: true - end end end end diff --git a/lib/gitlab/ci/config/node/commands.rb b/lib/gitlab/ci/config/node/commands.rb new file mode 100644 index 00000000000..d7657ae314b --- /dev/null +++ b/lib/gitlab/ci/config/node/commands.rb @@ -0,0 +1,33 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents a job script. + # + class Commands < Entry + include Validatable + + validations do + include LegacyValidationHelpers + + validate do + unless string_or_array_of_strings?(config) + errors.add(:config, + 'should be a string or an array of strings') + end + end + + def string_or_array_of_strings?(field) + validate_string(field) || validate_array_of_strings(field) + end + end + + def value + Array(@config) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index 37936fc8242..2de82d40c9d 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -25,10 +25,14 @@ module Gitlab private - def create_node(key, factory) - factory.with(value: @config[key], key: key, parent: self) + def compose! + self.class.nodes.each do |key, factory| + factory + .value(@config[key]) + .with(key: key, parent: self) - factory.create! + @entries[key] = factory.create! + end end class_methods do @@ -36,24 +40,25 @@ module Gitlab Hash[(@nodes || {}).map { |key, factory| [key, factory.dup] }] end - private + private # rubocop:disable Lint/UselessAccessModifier - def node(symbol, entry_class, metadata) - factory = Node::Factory.new(entry_class) + def node(key, node, metadata) + factory = Node::Factory.new(node) .with(description: metadata[:description]) - (@nodes ||= {}).merge!(symbol.to_sym => factory) + (@nodes ||= {}).merge!(key.to_sym => factory) end def helpers(*nodes) nodes.each do |symbol| define_method("#{symbol}_defined?") do - @nodes[symbol].try(:defined?) + @entries[symbol].specified? if @entries[symbol] end define_method("#{symbol}_value") do - raise Entry::InvalidError unless valid? - @nodes[symbol].try(:value) + return unless @entries[symbol] && @entries[symbol].valid? + + @entries[symbol].value end alias_method symbol.to_sym, "#{symbol}_value".to_sym diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 9e79e170a4f..0c782c422b5 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -8,30 +8,31 @@ module Gitlab class Entry class InvalidError < StandardError; end - attr_reader :config + attr_reader :config, :metadata attr_accessor :key, :parent, :description - def initialize(config) + def initialize(config, **metadata) @config = config - @nodes = {} + @metadata = metadata + @entries = {} + @validator = self.class.validator.new(self) - @validator.validate + @validator.validate(:new) end def process! - return if leaf? return unless valid? compose! - process_nodes! + descendants.each(&:process!) end - def nodes - @nodes.values + def leaf? + @entries.none? end - def leaf? - self.class.nodes.none? + def descendants + @entries.values end def ancestors @@ -43,27 +44,30 @@ module Gitlab end def errors - @validator.messages + nodes.flat_map(&:errors) + @validator.messages + descendants.flat_map(&:errors) end def value if leaf? @config else - defined = @nodes.select { |_key, value| value.defined? } - Hash[defined.map { |key, node| [key, node.value] }] + meaningful = @entries.select do |_key, value| + value.specified? && value.relevant? + end + + Hash[meaningful.map { |key, entry| [key, entry.value] }] end end - def defined? + def specified? true end - def self.default + def relevant? + true end - def self.nodes - {} + def self.default end def self.validator @@ -73,17 +77,6 @@ module Gitlab private def compose! - self.class.nodes.each do |key, essence| - @nodes[key] = create_node(key, essence) - end - end - - def process_nodes! - nodes.each(&:process!) - end - - def create_node(key, essence) - raise NotImplementedError end end end diff --git a/lib/gitlab/ci/config/node/factory.rb b/lib/gitlab/ci/config/node/factory.rb index 5919a283283..707b052e6a8 100644 --- a/lib/gitlab/ci/config/node/factory.rb +++ b/lib/gitlab/ci/config/node/factory.rb @@ -10,35 +10,60 @@ module Gitlab def initialize(node) @node = node + @metadata = {} @attributes = {} end + def value(value) + @value = value + self + end + + def metadata(metadata) + @metadata.merge!(metadata) + self + end + def with(attributes) @attributes.merge!(attributes) self end def create! - raise InvalidFactory unless @attributes.has_key?(:value) + raise InvalidFactory unless defined?(@value) - fabricate.tap do |entry| - entry.key = @attributes[:key] - entry.parent = @attributes[:parent] - entry.description = @attributes[:description] + ## + # We assume that unspecified entry is undefined. + # See issue #18775. + # + if @value.nil? + Node::Undefined.new( + fabricate_undefined + ) + else + fabricate(@node, @value) end end private - def fabricate + def fabricate_undefined ## - # We assume that unspecified entry is undefined. - # See issue #18775. + # If node has a default value we fabricate concrete node + # with default value. # - if @attributes[:value].nil? - Node::Undefined.new(@node) + if @node.default.nil? + fabricate(Node::Null) else - @node.new(@attributes[:value]) + fabricate(@node, @node.default) + end + end + + def fabricate(node, value = nil) + node.new(value, @metadata).tap do |entry| + entry.key = @attributes[:key] + entry.parent = @attributes[:parent] + entry.description = @attributes[:description] end end end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index f92e1eccbcf..ccd539fb003 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -34,10 +34,36 @@ module Gitlab description: 'Configure caching between build jobs.' helpers :before_script, :image, :services, :after_script, - :variables, :stages, :types, :cache + :variables, :stages, :types, :cache, :jobs - def stages - stages_defined? ? stages_value : types_value + private + + def compose! + super + + compose_jobs! + compose_deprecated_entries! + end + + def compose_jobs! + factory = Node::Factory.new(Node::Jobs) + .value(@config.except(*self.class.nodes.keys)) + .with(key: :jobs, parent: self, + description: 'Jobs definition for this pipeline') + + @entries[:jobs] = factory.create! + end + + def compose_deprecated_entries! + ## + # Deprecated `:types` key workaround - if types are defined and + # stages are not defined we use types definition as stages. + # + if types_defined? && !stages_defined? + @entries[:stages] = @entries[:types] + end + + @entries.delete(:types) end end end diff --git a/lib/gitlab/ci/config/node/hidden_job.rb b/lib/gitlab/ci/config/node/hidden_job.rb new file mode 100644 index 00000000000..073044b66f8 --- /dev/null +++ b/lib/gitlab/ci/config/node/hidden_job.rb @@ -0,0 +1,23 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents a hidden CI/CD job. + # + class HiddenJob < Entry + include Validatable + + validations do + validates :config, type: Hash + validates :config, presence: true + end + + def relevant? + false + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/job.rb b/lib/gitlab/ci/config/node/job.rb new file mode 100644 index 00000000000..e84737acbb9 --- /dev/null +++ b/lib/gitlab/ci/config/node/job.rb @@ -0,0 +1,123 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents a concrete CI/CD job. + # + class Job < Entry + include Configurable + include Attributable + + ALLOWED_KEYS = %i[tags script only except type image services allow_failure + type stage when artifacts cache dependencies before_script + after_script variables environment] + + attributes :tags, :allow_failure, :when, :environment, :dependencies + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + + validates :config, presence: true + validates :name, presence: true + validates :name, type: Symbol + + with_options allow_nil: true do + validates :tags, array_of_strings: true + validates :allow_failure, boolean: true + validates :when, + inclusion: { in: %w[on_success on_failure always manual], + message: 'should be on_success, on_failure, ' \ + 'always or manual' } + validates :environment, + type: { + with: String, + message: Gitlab::Regex.environment_name_regex_message } + validates :environment, + format: { + with: Gitlab::Regex.environment_name_regex, + message: Gitlab::Regex.environment_name_regex_message } + + validates :dependencies, array_of_strings: true + end + end + + node :before_script, Script, + description: 'Global before script overridden in this job.' + + node :script, Commands, + description: 'Commands that will be executed in this job.' + + node :stage, Stage, + description: 'Pipeline stage this job will be executed into.' + + node :type, Stage, + description: 'Deprecated: stage this job will be executed into.' + + node :after_script, Script, + description: 'Commands that will be executed when finishing job.' + + node :cache, Cache, + description: 'Cache definition for this job.' + + node :image, Image, + description: 'Image that will be used to execute this job.' + + node :services, Services, + description: 'Services that will be used to execute this job.' + + node :only, Trigger, + description: 'Refs policy this job will be executed for.' + + node :except, Trigger, + description: 'Refs policy this job will be executed for.' + + node :variables, Variables, + description: 'Environment variables available for this job.' + + node :artifacts, Artifacts, + description: 'Artifacts configuration for this job.' + + helpers :before_script, :script, :stage, :type, :after_script, + :cache, :image, :services, :only, :except, :variables, + :artifacts + + def name + @metadata[:name] + end + + def value + @config.merge(to_hash.compact) + end + + private + + def to_hash + { name: name, + before_script: before_script, + script: script, + image: image, + services: services, + stage: stage, + cache: cache, + only: only, + except: except, + variables: variables_defined? ? variables : nil, + artifacts: artifacts, + after_script: after_script } + end + + def compose! + super + + if type_defined? && !stage_defined? + @entries[:stage] = @entries[:type] + end + + @entries.delete(:type) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/jobs.rb b/lib/gitlab/ci/config/node/jobs.rb new file mode 100644 index 00000000000..51683c82ceb --- /dev/null +++ b/lib/gitlab/ci/config/node/jobs.rb @@ -0,0 +1,48 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents a set of jobs. + # + class Jobs < Entry + include Validatable + + validations do + validates :config, type: Hash + + validate do + unless has_visible_job? + errors.add(:config, 'should contain at least one visible job') + end + end + + def has_visible_job? + config.any? { |name, _| !hidden?(name) } + end + end + + def hidden?(name) + name.to_s.start_with?('.') + end + + private + + def compose! + @config.each do |name, config| + node = hidden?(name) ? Node::HiddenJob : Node::Job + + factory = Node::Factory.new(node) + .value(config || {}) + .metadata(name: name) + .with(key: name, parent: self, + description: "#{name} job definition.") + + @entries[name] = factory.create! + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/legacy_validation_helpers.rb b/lib/gitlab/ci/config/node/legacy_validation_helpers.rb index 4d9a508796a..0c291efe6a5 100644 --- a/lib/gitlab/ci/config/node/legacy_validation_helpers.rb +++ b/lib/gitlab/ci/config/node/legacy_validation_helpers.rb @@ -41,10 +41,6 @@ module Gitlab false end - def validate_environment(value) - value.is_a?(String) && value =~ Gitlab::Regex.environment_name_regex - end - def validate_boolean(value) value.in?([true, false]) end diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb new file mode 100644 index 00000000000..88a5f53f13c --- /dev/null +++ b/lib/gitlab/ci/config/node/null.rb @@ -0,0 +1,34 @@ +module Gitlab + module Ci + class Config + module Node + ## + # This class represents an undefined node. + # + # Implements the Null Object pattern. + # + class Null < Entry + def value + nil + end + + def valid? + true + end + + def errors + [] + end + + def specified? + false + end + + def relevant? + false + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/stage.rb b/lib/gitlab/ci/config/node/stage.rb new file mode 100644 index 00000000000..cbc97641f5a --- /dev/null +++ b/lib/gitlab/ci/config/node/stage.rb @@ -0,0 +1,22 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents a stage for a job. + # + class Stage < Entry + include Validatable + + validations do + validates :config, type: String + end + + def self.default + 'test' + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/trigger.rb b/lib/gitlab/ci/config/node/trigger.rb new file mode 100644 index 00000000000..d8b31975088 --- /dev/null +++ b/lib/gitlab/ci/config/node/trigger.rb @@ -0,0 +1,26 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Entry that represents a trigger policy for the job. + # + class Trigger < Entry + include Validatable + + validations do + include LegacyValidationHelpers + + validate :array_of_strings_or_regexps + + def array_of_strings_or_regexps + unless validate_array_of_strings_or_regexps(config) + errors.add(:config, 'should be an array of strings or regexps') + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/undefined.rb b/lib/gitlab/ci/config/node/undefined.rb index 699605e1e3a..45fef8c3ae5 100644 --- a/lib/gitlab/ci/config/node/undefined.rb +++ b/lib/gitlab/ci/config/node/undefined.rb @@ -3,24 +3,13 @@ module Gitlab class Config module Node ## - # This class represents an undefined entry node. + # This class represents an unspecified entry node. # - # It takes original entry class as configuration and returns default - # value of original entry as self value. + # It decorates original entry adding method that indicates it is + # unspecified. # - # - class Undefined < Entry - include Validatable - - validations do - validates :config, type: Class - end - - def value - @config.default - end - - def defined? + class Undefined < SimpleDelegator + def specified? false end end diff --git a/lib/gitlab/ci/config/node/validator.rb b/lib/gitlab/ci/config/node/validator.rb index 758a6cf4356..43c7e102b50 100644 --- a/lib/gitlab/ci/config/node/validator.rb +++ b/lib/gitlab/ci/config/node/validator.rb @@ -21,18 +21,19 @@ module Gitlab 'Validator' end - def unknown_keys - return [] unless config.is_a?(Hash) - - config.keys - @node.class.nodes.keys - end - private def location predecessors = ancestors.map(&:key).compact - current = key || @node.class.name.demodulize.underscore - predecessors.append(current).join(':') + predecessors.append(key_name).join(':') + end + + def key_name + if key.blank? + @node.class.name.demodulize.underscore.humanize + else + key + end end end end diff --git a/lib/gitlab/ci/config/node/validators.rb b/lib/gitlab/ci/config/node/validators.rb index 7b2f57990b5..e20908ad3cb 100644 --- a/lib/gitlab/ci/config/node/validators.rb +++ b/lib/gitlab/ci/config/node/validators.rb @@ -5,10 +5,11 @@ module Gitlab module Validators class AllowedKeysValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - if record.unknown_keys.any? - unknown_list = record.unknown_keys.join(', ') - record.errors.add(:config, - "contains unknown keys: #{unknown_list}") + unknown_keys = record.config.try(:keys).to_a - options[:in] + + if unknown_keys.any? + record.errors.add(:config, 'contains unknown keys: ' + + unknown_keys.join(', ')) end end end @@ -33,6 +34,16 @@ module Gitlab end end + class DurationValidator < ActiveModel::EachValidator + include LegacyValidationHelpers + + def validate_each(record, attribute, value) + unless validate_duration(value) + record.errors.add(attribute, 'should be a duration') + end + end + end + class KeyValidator < ActiveModel::EachValidator include LegacyValidationHelpers @@ -49,7 +60,8 @@ module Gitlab raise unless type.is_a?(Class) unless value.is_a?(type) - record.errors.add(attribute, "should be a #{type.name}") + message = options[:message] || "should be a #{type.name}" + record.errors.add(attribute, message) end end end diff --git a/lib/gitlab/closing_issue_extractor.rb b/lib/gitlab/closing_issue_extractor.rb index 9bef9037ad6..58f86abc5c4 100644 --- a/lib/gitlab/closing_issue_extractor.rb +++ b/lib/gitlab/closing_issue_extractor.rb @@ -22,7 +22,9 @@ module Gitlab @extractor.analyze(closing_statements.join(" ")) - @extractor.issues + @extractor.issues.reject do |issue| + @extractor.project.forked_from?(issue.project) # Don't extract issues on original project + end end end end diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 078609c86f1..55b8f888d53 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -55,12 +55,12 @@ module Gitlab end end - private - def self.connection ActiveRecord::Base.connection end + private_class_method :connection + def self.database_version row = connection.execute("SELECT VERSION()").first @@ -70,5 +70,7 @@ module Gitlab row.first end end + + private_class_method :database_version end end diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb index b09ca1fb8b0..e47df508ca2 100644 --- a/lib/gitlab/diff/file.rb +++ b/lib/gitlab/diff/file.rb @@ -63,15 +63,18 @@ module Gitlab diff_refs.try(:head_sha) end + attr_writer :highlighted_diff_lines + # Array of Gitlab::Diff::Line objects def diff_lines - @lines ||= Gitlab::Diff::Parser.new.parse(raw_diff.each_line).to_a + @diff_lines ||= Gitlab::Diff::Parser.new.parse(raw_diff.each_line).to_a end def highlighted_diff_lines @highlighted_diff_lines ||= Gitlab::Diff::Highlight.new(self, repository: self.repository).highlight end + # Array[<Hash>] with right/left keys that contains Gitlab::Diff::Line objects which text is hightlighted def parallel_diff_lines @parallel_diff_lines ||= Gitlab::Diff::ParallelDiff.new(self).parallelize end diff --git a/lib/gitlab/diff/file_collection/base.rb b/lib/gitlab/diff/file_collection/base.rb new file mode 100644 index 00000000000..2b9fc65b985 --- /dev/null +++ b/lib/gitlab/diff/file_collection/base.rb @@ -0,0 +1,35 @@ +module Gitlab + module Diff + module FileCollection + class Base + attr_reader :project, :diff_options, :diff_view, :diff_refs + + delegate :count, :size, :real_size, to: :diff_files + + def self.default_options + ::Commit.max_diff_options.merge(ignore_whitespace_change: false, no_collapse: false) + end + + def initialize(diffable, project:, diff_options: nil, diff_refs: nil) + diff_options = self.class.default_options.merge(diff_options || {}) + + @diffable = diffable + @diffs = diffable.raw_diffs(diff_options) + @project = project + @diff_options = diff_options + @diff_refs = diff_refs + end + + def diff_files + @diff_files ||= @diffs.decorate! { |diff| decorate_diff!(diff) } + end + + private + + def decorate_diff!(diff) + Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs) + end + end + end + end +end diff --git a/lib/gitlab/diff/file_collection/commit.rb b/lib/gitlab/diff/file_collection/commit.rb new file mode 100644 index 00000000000..4dc297ec036 --- /dev/null +++ b/lib/gitlab/diff/file_collection/commit.rb @@ -0,0 +1,14 @@ +module Gitlab + module Diff + module FileCollection + class Commit < Base + def initialize(commit, diff_options:) + super(commit, + project: commit.project, + diff_options: diff_options, + diff_refs: commit.diff_refs) + end + end + end + end +end diff --git a/lib/gitlab/diff/file_collection/compare.rb b/lib/gitlab/diff/file_collection/compare.rb new file mode 100644 index 00000000000..20d8f891cc3 --- /dev/null +++ b/lib/gitlab/diff/file_collection/compare.rb @@ -0,0 +1,14 @@ +module Gitlab + module Diff + module FileCollection + class Compare < Base + def initialize(compare, project:, diff_options:, diff_refs: nil) + super(compare, + project: project, + diff_options: diff_options, + diff_refs: diff_refs) + end + end + end + end +end diff --git a/lib/gitlab/diff/file_collection/merge_request.rb b/lib/gitlab/diff/file_collection/merge_request.rb new file mode 100644 index 00000000000..4f946908e2f --- /dev/null +++ b/lib/gitlab/diff/file_collection/merge_request.rb @@ -0,0 +1,73 @@ +module Gitlab + module Diff + module FileCollection + class MergeRequest < Base + def initialize(merge_request, diff_options:) + @merge_request = merge_request + + super(merge_request, + project: merge_request.project, + diff_options: diff_options, + diff_refs: merge_request.diff_refs) + end + + def diff_files + super.tap { |_| store_highlight_cache } + end + + private + + # Extracted method to highlight in the same iteration to the diff_collection. + def decorate_diff!(diff) + diff_file = super + cache_highlight!(diff_file) if cacheable? + diff_file + end + + def highlight_diff_file_from_cache!(diff_file, cache_diff_lines) + diff_file.highlighted_diff_lines = cache_diff_lines.map do |line| + Gitlab::Diff::Line.init_from_hash(line) + end + end + + # + # If we find the highlighted diff files lines on the cache we replace existing diff_files lines (no highlighted) + # for the highlighted ones, so we just skip their execution. + # If the highlighted diff files lines are not cached we calculate and cache them. + # + # The content of the cache is a Hash where the key correspond to the file_path and the values are Arrays of + # hashes that represent serialized diff lines. + # + def cache_highlight!(diff_file) + file_path = diff_file.file_path + + if highlight_cache[file_path] + highlight_diff_file_from_cache!(diff_file, highlight_cache[file_path]) + else + highlight_cache[file_path] = diff_file.highlighted_diff_lines.map(&:to_hash) + end + end + + def highlight_cache + return @highlight_cache if defined?(@highlight_cache) + + @highlight_cache = Rails.cache.read(cache_key) || {} + @highlight_cache_was_empty = @highlight_cache.empty? + @highlight_cache + end + + def store_highlight_cache + Rails.cache.write(cache_key, highlight_cache) if @highlight_cache_was_empty + end + + def cacheable? + @merge_request.merge_request_diff.present? + end + + def cache_key + [@merge_request.merge_request_diff, 'highlighted-diff-files', diff_options] + end + end + end + end +end diff --git a/lib/gitlab/diff/highlight.rb b/lib/gitlab/diff/highlight.rb index 649a265a02c..9ea976e18fa 100644 --- a/lib/gitlab/diff/highlight.rb +++ b/lib/gitlab/diff/highlight.rb @@ -40,8 +40,6 @@ module Gitlab def highlight_line(diff_line) return unless diff_file && diff_file.diff_refs - line_prefix = diff_line.text.match(/\A(.)/) ? $1 : ' ' - rich_line = if diff_line.unchanged? || diff_line.added? new_lines[diff_line.new_pos - 1] @@ -51,7 +49,10 @@ module Gitlab # Only update text if line is found. This will prevent # issues with submodules given the line only exists in diff content. - "#{line_prefix}#{rich_line}".html_safe if rich_line + if rich_line + line_prefix = diff_line.text.match(/\A(.)/) ? $1 : ' ' + "#{line_prefix}#{rich_line}".html_safe + end end def inline_diffs diff --git a/lib/gitlab/diff/inline_diff.rb b/lib/gitlab/diff/inline_diff.rb index 28ad637fda4..55708d42161 100644 --- a/lib/gitlab/diff/inline_diff.rb +++ b/lib/gitlab/diff/inline_diff.rb @@ -19,24 +19,6 @@ module Gitlab attr_accessor :old_line, :new_line, :offset - def self.for_lines(lines) - changed_line_pairs = self.find_changed_line_pairs(lines) - - inline_diffs = [] - - changed_line_pairs.each do |old_index, new_index| - old_line = lines[old_index] - new_line = lines[new_index] - - old_diffs, new_diffs = new(old_line, new_line, offset: 1).inline_diffs - - inline_diffs[old_index] = old_diffs - inline_diffs[new_index] = new_diffs - end - - inline_diffs - end - def initialize(old_line, new_line, offset: 0) @old_line = old_line[offset..-1] @new_line = new_line[offset..-1] @@ -63,32 +45,54 @@ module Gitlab [old_diffs, new_diffs] end - private + class << self + def for_lines(lines) + changed_line_pairs = find_changed_line_pairs(lines) - # Finds pairs of old/new line pairs that represent the same line that changed - def self.find_changed_line_pairs(lines) - # Prefixes of all diff lines, indicating their types - # For example: `" - + -+ ---+++ --+ -++"` - line_prefixes = lines.each_with_object("") { |line, s| s << line[0] }.gsub(/[^ +-]/, ' ') + inline_diffs = [] - changed_line_pairs = [] - line_prefixes.scan(LINE_PAIRS_PATTERN) do - # For `"---+++"`, `begin_index == 0`, `end_index == 6` - begin_index, end_index = Regexp.last_match.offset(:del_ins) + changed_line_pairs.each do |old_index, new_index| + old_line = lines[old_index] + new_line = lines[new_index] - # For `"---+++"`, `changed_line_count == 3` - changed_line_count = (end_index - begin_index) / 2 + old_diffs, new_diffs = new(old_line, new_line, offset: 1).inline_diffs - halfway_index = begin_index + changed_line_count - (begin_index...halfway_index).each do |i| - # For `"---+++"`, index 1 maps to 1 + 3 = 4 - changed_line_pairs << [i, i + changed_line_count] + inline_diffs[old_index] = old_diffs + inline_diffs[new_index] = new_diffs end + + inline_diffs end - changed_line_pairs + private + + # Finds pairs of old/new line pairs that represent the same line that changed + def find_changed_line_pairs(lines) + # Prefixes of all diff lines, indicating their types + # For example: `" - + -+ ---+++ --+ -++"` + line_prefixes = lines.each_with_object("") { |line, s| s << line[0] }.gsub(/[^ +-]/, ' ') + + changed_line_pairs = [] + line_prefixes.scan(LINE_PAIRS_PATTERN) do + # For `"---+++"`, `begin_index == 0`, `end_index == 6` + begin_index, end_index = Regexp.last_match.offset(:del_ins) + + # For `"---+++"`, `changed_line_count == 3` + changed_line_count = (end_index - begin_index) / 2 + + halfway_index = begin_index + changed_line_count + (begin_index...halfway_index).each do |i| + # For `"---+++"`, index 1 maps to 1 + 3 = 4 + changed_line_pairs << [i, i + changed_line_count] + end + end + + changed_line_pairs + end end + private + def longest_common_prefix(a, b) max_length = [a.length, b.length].max diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index c6189d660c2..cf097e0d0de 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -9,6 +9,20 @@ module Gitlab @old_pos, @new_pos = old_pos, new_pos end + def self.init_from_hash(hash) + new(hash[:text], hash[:type], hash[:index], hash[:old_pos], hash[:new_pos]) + end + + def serialize_keys + @serialize_keys ||= %i(text type index old_pos new_pos) + end + + def to_hash + hash = {} + serialize_keys.each { |key| hash[key] = send(key) } + hash + end + def old_line old_pos unless added? || meta? end diff --git a/lib/gitlab/email/handler.rb b/lib/gitlab/email/handler.rb new file mode 100644 index 00000000000..bd3267e2a80 --- /dev/null +++ b/lib/gitlab/email/handler.rb @@ -0,0 +1,17 @@ +require 'gitlab/email/handler/create_note_handler' +require 'gitlab/email/handler/create_issue_handler' + +module Gitlab + module Email + module Handler + HANDLERS = [CreateNoteHandler, CreateIssueHandler] + + def self.for(mail, mail_key) + HANDLERS.find do |klass| + handler = klass.new(mail, mail_key) + break handler if handler.can_handle? + end + end + end + end +end diff --git a/lib/gitlab/email/handler/base_handler.rb b/lib/gitlab/email/handler/base_handler.rb new file mode 100644 index 00000000000..b7ed11cb638 --- /dev/null +++ b/lib/gitlab/email/handler/base_handler.rb @@ -0,0 +1,60 @@ +module Gitlab + module Email + module Handler + class BaseHandler + attr_reader :mail, :mail_key + + def initialize(mail, mail_key) + @mail = mail + @mail_key = mail_key + end + + def message + @message ||= process_message + end + + def author + raise NotImplementedError + end + + def project + raise NotImplementedError + end + + private + + def validate_permission!(permission) + raise UserNotFoundError unless author + raise UserBlockedError if author.blocked? + raise ProjectNotFound unless author.can?(:read_project, project) + raise UserNotAuthorizedError unless author.can?(permission, project) + end + + def process_message + message = ReplyParser.new(mail).execute.strip + add_attachments(message) + end + + def add_attachments(reply) + attachments = Email::AttachmentUploader.new(mail).execute(project) + + reply + attachments.map do |link| + "\n\n#{link[:markdown]}" + end.join + end + + def verify_record!(record:, invalid_exception:, record_name:) + return if record.persisted? + + error_title = "The #{record_name} could not be created for the following reasons:" + + msg = error_title + record.errors.full_messages.map do |error| + "\n\n- #{error}" + end.join + + raise invalid_exception, msg + end + end + end + end +end diff --git a/lib/gitlab/email/handler/create_issue_handler.rb b/lib/gitlab/email/handler/create_issue_handler.rb new file mode 100644 index 00000000000..4e6566af8ab --- /dev/null +++ b/lib/gitlab/email/handler/create_issue_handler.rb @@ -0,0 +1,52 @@ + +require 'gitlab/email/handler/base_handler' + +module Gitlab + module Email + module Handler + class CreateIssueHandler < BaseHandler + attr_reader :project_path, :authentication_token + + def initialize(mail, mail_key) + super(mail, mail_key) + @project_path, @authentication_token = + mail_key && mail_key.split('+', 2) + end + + def can_handle? + !authentication_token.nil? + end + + def execute + raise ProjectNotFound unless project + + validate_permission!(:create_issue) + + verify_record!( + record: create_issue, + invalid_exception: InvalidIssueError, + record_name: 'issue') + end + + def author + @author ||= User.find_by(authentication_token: authentication_token) + end + + def project + @project ||= Project.find_with_namespace(project_path) + end + + private + + def create_issue + Issues::CreateService.new( + project, + author, + title: mail.subject, + description: message + ).execute + end + end + end + end +end diff --git a/lib/gitlab/email/handler/create_note_handler.rb b/lib/gitlab/email/handler/create_note_handler.rb new file mode 100644 index 00000000000..06dae31cc27 --- /dev/null +++ b/lib/gitlab/email/handler/create_note_handler.rb @@ -0,0 +1,55 @@ + +require 'gitlab/email/handler/base_handler' + +module Gitlab + module Email + module Handler + class CreateNoteHandler < BaseHandler + def can_handle? + mail_key =~ /\A\w+\z/ + end + + def execute + raise SentNotificationNotFoundError unless sent_notification + raise AutoGeneratedEmailError if mail.header.to_s =~ /auto-(generated|replied)/ + + validate_permission!(:create_note) + + raise NoteableNotFoundError unless sent_notification.noteable + raise EmptyEmailError if message.blank? + + verify_record!( + record: create_note, + invalid_exception: InvalidNoteError, + record_name: 'comment') + end + + def author + sent_notification.recipient + end + + def project + sent_notification.project + end + + def sent_notification + @sent_notification ||= SentNotification.for(mail_key) + end + + private + + def create_note + Notes::CreateService.new( + project, + author, + note: message, + noteable_type: sent_notification.noteable_type, + noteable_id: sent_notification.noteable_id, + commit_id: sent_notification.commit_id, + line_code: sent_notification.line_code + ).execute + end + end + end + end +end diff --git a/lib/gitlab/email/message/repository_push.rb b/lib/gitlab/email/message/repository_push.rb index 97701b0cd42..0e3b65fceb4 100644 --- a/lib/gitlab/email/message/repository_push.rb +++ b/lib/gitlab/email/message/repository_push.rb @@ -35,21 +35,22 @@ module Gitlab def commits return unless compare - @commits ||= Commit.decorate(compare.commits, project) + @commits ||= compare.commits end def diffs return unless compare - - @diffs ||= safe_diff_files(compare.diffs(max_files: 30), diff_refs: diff_refs, repository: project.repository) + + # This diff is more moderated in number of files and lines + @diffs ||= compare.diffs(max_files: 30, max_lines: 5000, no_collapse: true).diff_files end def diffs_count - diffs.count if diffs + diffs.size if diffs end def compare - @opts[:compare] + @opts[:compare] if @opts[:compare] end def diff_refs @@ -97,16 +98,18 @@ module Gitlab if commits.length > 1 namespace_project_compare_url(project_namespace, project, - from: Commit.new(compare.base, project), - to: Commit.new(compare.head, project)) + from: compare.start_commit, + to: compare.head_commit) else namespace_project_commit_url(project_namespace, - project, commits.first) + project, + commits.first) end else unless @action == :delete namespace_project_tree_url(project_namespace, - project, ref_name) + project, + ref_name) end end end diff --git a/lib/gitlab/email/receiver.rb b/lib/gitlab/email/receiver.rb index 1c671a7487b..9213cfb51e8 100644 --- a/lib/gitlab/email/receiver.rb +++ b/lib/gitlab/email/receiver.rb @@ -1,18 +1,24 @@ + +require 'gitlab/email/handler' + # Inspired in great part by Discourse's Email::Receiver module Gitlab module Email - class Receiver - class ProcessingError < StandardError; end - class EmailUnparsableError < ProcessingError; end - class SentNotificationNotFoundError < ProcessingError; end - class EmptyEmailError < ProcessingError; end - class AutoGeneratedEmailError < ProcessingError; end - class UserNotFoundError < ProcessingError; end - class UserBlockedError < ProcessingError; end - class UserNotAuthorizedError < ProcessingError; end - class NoteableNotFoundError < ProcessingError; end - class InvalidNoteError < ProcessingError; end + class ProcessingError < StandardError; end + class EmailUnparsableError < ProcessingError; end + class SentNotificationNotFoundError < ProcessingError; end + class ProjectNotFound < ProcessingError; end + class EmptyEmailError < ProcessingError; end + class AutoGeneratedEmailError < ProcessingError; end + class UserNotFoundError < ProcessingError; end + class UserBlockedError < ProcessingError; end + class UserNotAuthorizedError < ProcessingError; end + class NoteableNotFoundError < ProcessingError; end + class InvalidNoteError < ProcessingError; end + class InvalidIssueError < ProcessingError; end + class UnknownIncomingEmail < ProcessingError; end + class Receiver def initialize(raw) @raw = raw end @@ -20,91 +26,38 @@ module Gitlab def execute raise EmptyEmailError if @raw.blank? - raise SentNotificationNotFoundError unless sent_notification - - raise AutoGeneratedEmailError if message.header.to_s =~ /auto-(generated|replied)/ - - author = sent_notification.recipient - - raise UserNotFoundError unless author - - raise UserBlockedError if author.blocked? - - project = sent_notification.project - - raise UserNotAuthorizedError unless project && author.can?(:create_note, project) - - raise NoteableNotFoundError unless sent_notification.noteable - - reply = ReplyParser.new(message).execute.strip - - raise EmptyEmailError if reply.blank? - - reply = add_attachments(reply) - - note = create_note(reply) + mail = build_mail + mail_key = extract_mail_key(mail) + handler = Handler.for(mail, mail_key) - unless note.persisted? - msg = "The comment could not be created for the following reasons:" - note.errors.full_messages.each do |error| - msg << "\n\n- #{error}" - end + raise UnknownIncomingEmail unless handler - raise InvalidNoteError, msg - end + handler.execute end - private - - def message - @message ||= Mail::Message.new(@raw) - rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e + def build_mail + Mail::Message.new(@raw) + rescue Encoding::UndefinedConversionError, + Encoding::InvalidByteSequenceError => e raise EmailUnparsableError, e end - def reply_key - key_from_to_header || key_from_additional_headers + def extract_mail_key(mail) + key_from_to_header(mail) || key_from_additional_headers(mail) end - def key_from_to_header - key = nil - message.to.each do |address| + def key_from_to_header(mail) + mail.to.find do |address| key = Gitlab::IncomingEmail.key_from_address(address) - break if key + break key if key end - - key end - def key_from_additional_headers - reply_key = nil - - Array(message.references).each do |message_id| - reply_key = Gitlab::IncomingEmail.key_from_fallback_reply_message_id(message_id) - break if reply_key + def key_from_additional_headers(mail) + Array(mail.references).find do |mail_id| + key = Gitlab::IncomingEmail.key_from_fallback_message_id(mail_id) + break key if key end - - reply_key - end - - def sent_notification - return nil unless reply_key - - SentNotification.for(reply_key) - end - - def add_attachments(reply) - attachments = Email::AttachmentUploader.new(message).execute(sent_notification.project) - - attachments.each do |link| - reply << "\n\n#{link[:markdown]}" - end - - reply - end - - def create_note(reply) - sent_notification.create_note(reply) end end end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 8e8f39d9cb2..69943e22353 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -14,7 +14,7 @@ module Gitlab @user_access = UserAccess.new(user, project: project) end - def check(cmd, changes = nil) + def check(cmd, changes) return build_status_object(false, "Git access over #{protocol.upcase} is not allowed") unless protocol_allowed? unless actor diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb index a088e19d1e7..d32bdd86427 100644 --- a/lib/gitlab/git_post_receive.rb +++ b/lib/gitlab/git_post_receive.rb @@ -39,7 +39,6 @@ module Gitlab end def deserialize_changes(changes) - changes = Base64.decode64(changes) unless changes.include?(' ') changes = utf8_encode_changes(changes) changes.lines end diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index d6d14bd98a0..bb562bdcd2c 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -2,7 +2,7 @@ module Gitlab module ImportExport extend self - VERSION = '0.1.2' + VERSION = '0.1.3' FILENAME_LIMIT = 50 def export_path(relative_path:) @@ -13,6 +13,10 @@ module Gitlab File.join(Settings.shared['path'], 'tmp/project_exports') end + def import_upload_path(filename:) + File.join(storage_path, 'uploads', filename) + end + def project_filename "project.json" end diff --git a/lib/gitlab/import_export/avatar_restorer.rb b/lib/gitlab/import_export/avatar_restorer.rb index 352539eb594..cfa595629f4 100644 --- a/lib/gitlab/import_export/avatar_restorer.rb +++ b/lib/gitlab/import_export/avatar_restorer.rb @@ -1,7 +1,6 @@ module Gitlab module ImportExport class AvatarRestorer - def initialize(project:, shared:) @project = project @shared = shared diff --git a/lib/gitlab/import_export/command_line_util.rb b/lib/gitlab/import_export/command_line_util.rb index 5dd0e34c18e..e522a0fc8f6 100644 --- a/lib/gitlab/import_export/command_line_util.rb +++ b/lib/gitlab/import_export/command_line_util.rb @@ -17,6 +17,10 @@ module Gitlab execute(%W(#{git_bin_path} clone --bare #{bundle_path} #{repo_path})) end + def git_restore_hooks + execute(%W(#{Gitlab.config.gitlab_shell.path}/bin/create-hooks) + repository_storage_paths_args) + end + private def tar_with_options(archive:, dir:, options:) @@ -45,6 +49,10 @@ module Gitlab FileUtils.copy_entry(source, destination) true end + + def repository_storage_paths_args + Gitlab.config.repositories.storages.values + end end end end diff --git a/lib/gitlab/import_export/file_importer.rb b/lib/gitlab/import_export/file_importer.rb index 82d1e1805c5..eca6e5b6d51 100644 --- a/lib/gitlab/import_export/file_importer.rb +++ b/lib/gitlab/import_export/file_importer.rb @@ -3,6 +3,8 @@ module Gitlab class FileImporter include Gitlab::ImportExport::CommandLineUtil + MAX_RETRIES = 8 + def self.import(*args) new(*args).import end @@ -14,7 +16,10 @@ module Gitlab def import FileUtils.mkdir_p(@shared.export_path) - decompress_archive + + wait_for_archived_file do + decompress_archive + end rescue => e @shared.error(e) false @@ -22,6 +27,17 @@ module Gitlab private + # Exponentially sleep until I/O finishes copying the file + def wait_for_archived_file + MAX_RETRIES.times do |retry_number| + break if File.exist?(@archive_file) + + sleep(2**retry_number) + end + + yield + end + def decompress_archive result = untar_zxf(archive: @archive_file, dir: @shared.export_path) diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 15afe8174a4..1da51043611 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -3,11 +3,12 @@ project_tree: - issues: - :events - notes: - - :author - - :events - - :labels - - milestones: - - :events + - :author + - :events + - label_links: + - :label + - milestone: + - :events - snippets: - notes: :author @@ -20,6 +21,10 @@ project_tree: - :events - :merge_request_diff - :events + - label_links: + - :label + - milestone: + - :events - pipelines: - notes: - :author @@ -31,6 +36,9 @@ project_tree: - :services - :hooks - :protected_branches + - :labels + - milestones: + - :events # Only include the following attributes for the models specified. included_attributes: @@ -55,6 +63,10 @@ excluded_attributes: - :expired_at merge_request_diff: - :st_diffs + issues: + - :milestone_id + merge_requests: + - :milestone_id methods: statuses: diff --git a/lib/gitlab/import_export/json_hash_builder.rb b/lib/gitlab/import_export/json_hash_builder.rb new file mode 100644 index 00000000000..008300bde45 --- /dev/null +++ b/lib/gitlab/import_export/json_hash_builder.rb @@ -0,0 +1,110 @@ +module Gitlab + module ImportExport + # Generates a hash that conforms with http://apidock.com/rails/Hash/to_json + # and its peculiar options. + class JsonHashBuilder + def self.build(model_objects, attributes_finder) + new(model_objects, attributes_finder).build + end + + def initialize(model_objects, attributes_finder) + @model_objects = model_objects + @attributes_finder = attributes_finder + end + + def build + process_model_objects(@model_objects) + end + + private + + # Called when the model is actually a hash containing other relations (more models) + # Returns the config in the right format for calling +to_json+ + # + # +model_object_hash+ - A model relationship such as: + # {:merge_requests=>[:merge_request_diff, :notes]} + def process_model_objects(model_object_hash) + json_config_hash = {} + current_key = model_object_hash.keys.first + + model_object_hash.values.flatten.each do |model_object| + @attributes_finder.parse(current_key) { |hash| json_config_hash[current_key] ||= hash } + handle_model_object(current_key, model_object, json_config_hash) + end + + json_config_hash + end + + # Creates or adds to an existing hash an individual model or list + # + # +current_key+ main model that will be a key in the hash + # +model_object+ model or list of models to include in the hash + # +json_config_hash+ the original hash containing the root model + def handle_model_object(current_key, model_object, json_config_hash) + model_or_sub_model = model_object.is_a?(Hash) ? process_model_objects(model_object) : model_object + + if json_config_hash[current_key] + add_model_value(current_key, model_or_sub_model, json_config_hash) + else + create_model_value(current_key, model_or_sub_model, json_config_hash) + end + end + + # Constructs a new hash that will hold the configuration for that particular object + # It may include exceptions or other attribute detail configuration, parsed by +@attributes_finder+ + # + # +current_key+ main model that will be a key in the hash + # +value+ existing model to be included in the hash + # +json_config_hash+ the original hash containing the root model + def create_model_value(current_key, value, json_config_hash) + parsed_hash = { include: value } + parse_hash(value, parsed_hash) + + json_config_hash[current_key] = parsed_hash + end + + # Calls attributes finder to parse the hash and add any attributes to it + # + # +value+ existing model to be included in the hash + # +parsed_hash+ the original hash + def parse_hash(value, parsed_hash) + @attributes_finder.parse(value) do |hash| + parsed_hash = { include: hash_or_merge(value, hash) } + end + end + + # Adds new model configuration to an existing hash with key +current_key+ + # It may include exceptions or other attribute detail configuration, parsed by +@attributes_finder+ + # + # +current_key+ main model that will be a key in the hash + # +value+ existing model to be included in the hash + # +json_config_hash+ the original hash containing the root model + def add_model_value(current_key, value, json_config_hash) + @attributes_finder.parse(value) { |hash| value = { value => hash } } + + add_to_array(current_key, json_config_hash, value) + end + + # Adds new model configuration to an existing hash with key +current_key+ + # it creates a new array if it was previously a single value + # + # +current_key+ main model that will be a key in the hash + # +value+ existing model to be included in the hash + # +json_config_hash+ the original hash containing the root model + def add_to_array(current_key, json_config_hash, value) + old_values = json_config_hash[current_key][:include] + + json_config_hash[current_key][:include] = ([old_values] + [value]).compact.flatten + end + + # Construct a new hash or merge with an existing one a model configuration + # This is to fulfil +to_json+ requirements. + # + # +hash+ hash containing configuration generated mainly from +@attributes_finder+ + # +value+ existing model to be included in the hash + def hash_or_merge(value, hash) + value.is_a?(Hash) ? value.merge(hash) : { value => hash } + end + end + end +end diff --git a/lib/gitlab/import_export/members_mapper.rb b/lib/gitlab/import_export/members_mapper.rb index b459054c198..36c4cf6efa0 100644 --- a/lib/gitlab/import_export/members_mapper.rb +++ b/lib/gitlab/import_export/members_mapper.rb @@ -18,11 +18,14 @@ module Gitlab @map ||= begin @exported_members.inject(missing_keys_tracking_hash) do |hash, member| - existing_user = User.where(find_project_user_query(member)).first - old_user_id = member['user']['id'] - if existing_user && add_user_as_team_member(existing_user, member) - hash[old_user_id] = existing_user.id + if member['user'] + old_user_id = member['user']['id'] + existing_user = User.where(find_project_user_query(member)).first + hash[old_user_id] = existing_user.id if existing_user && add_team_member(member, existing_user) + else + add_team_member(member) end + hash end end @@ -45,7 +48,7 @@ module Gitlab ProjectMember.create!(user: @user, access_level: ProjectMember::MASTER, source_id: @project.id, importing: true) end - def add_user_as_team_member(existing_user, member) + def add_team_member(member, existing_user = nil) member['user'] = existing_user ProjectMember.create(member_hash(member)).persisted? diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index 051110c23cf..c7b3551b84c 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -47,7 +47,7 @@ module Gitlab relation_key = relation.is_a?(Hash) ? relation.keys.first : relation relation_hash = create_relation(relation_key, @tree_hash[relation_key.to_s]) - saved << restored_project.update_attribute(relation_key, relation_hash) + saved << restored_project.append_or_update_attribute(relation_key, relation_hash) end saved.all? end @@ -78,7 +78,7 @@ module Gitlab relation_key = relation.keys.first.to_s return if tree_hash[relation_key].blank? - tree_hash[relation_key].each do |relation_item| + [tree_hash[relation_key]].flatten.each do |relation_item| relation.values.flatten.each do |sub_relation| # We just use author to get the user ID, do not attempt to create an instance. next if sub_relation == :author diff --git a/lib/gitlab/import_export/reader.rb b/lib/gitlab/import_export/reader.rb index 15f5dd31035..5021a1a14ce 100644 --- a/lib/gitlab/import_export/reader.rb +++ b/lib/gitlab/import_export/reader.rb @@ -29,87 +29,12 @@ module Gitlab def build_hash(model_list) model_list.map do |model_objects| if model_objects.is_a?(Hash) - build_json_config_hash(model_objects) + Gitlab::ImportExport::JsonHashBuilder.build(model_objects, @attributes_finder) else @attributes_finder.find(model_objects) end end end - - # Called when the model is actually a hash containing other relations (more models) - # Returns the config in the right format for calling +to_json+ - # +model_object_hash+ - A model relationship such as: - # {:merge_requests=>[:merge_request_diff, :notes]} - def build_json_config_hash(model_object_hash) - @json_config_hash = {} - - model_object_hash.values.flatten.each do |model_object| - current_key = model_object_hash.keys.first - - @attributes_finder.parse(current_key) { |hash| @json_config_hash[current_key] ||= hash } - - handle_model_object(current_key, model_object) - process_sub_model(current_key, model_object) if model_object.is_a?(Hash) - end - @json_config_hash - end - - # If the model is a hash, process the sub_models, which could also be hashes - # If there is a list, add to an existing array, otherwise use hash syntax - # +current_key+ main model that will be a key in the hash - # +model_object+ model or list of models to include in the hash - def process_sub_model(current_key, model_object) - sub_model_json = build_json_config_hash(model_object).dup - @json_config_hash.slice!(current_key) - - if @json_config_hash[current_key] && @json_config_hash[current_key][:include] - @json_config_hash[current_key][:include] << sub_model_json - else - @json_config_hash[current_key] = { include: sub_model_json } - end - end - - # Creates or adds to an existing hash an individual model or list - # +current_key+ main model that will be a key in the hash - # +model_object+ model or list of models to include in the hash - def handle_model_object(current_key, model_object) - if @json_config_hash[current_key] - add_model_value(current_key, model_object) - else - create_model_value(current_key, model_object) - end - end - - # Constructs a new hash that will hold the configuration for that particular object - # It may include exceptions or other attribute detail configuration, parsed by +@attributes_finder+ - # +current_key+ main model that will be a key in the hash - # +value+ existing model to be included in the hash - def create_model_value(current_key, value) - parsed_hash = { include: value } - - @attributes_finder.parse(value) do |hash| - parsed_hash = { include: hash_or_merge(value, hash) } - end - @json_config_hash[current_key] = parsed_hash - end - - # Adds new model configuration to an existing hash with key +current_key+ - # It may include exceptions or other attribute detail configuration, parsed by +@attributes_finder+ - # +current_key+ main model that will be a key in the hash - # +value+ existing model to be included in the hash - def add_model_value(current_key, value) - @attributes_finder.parse(value) { |hash| value = { value => hash } } - old_values = @json_config_hash[current_key][:include] - @json_config_hash[current_key][:include] = ([old_values] + [value]).compact.flatten - end - - # Construct a new hash or merge with an existing one a model configuration - # This is to fulfil +to_json+ requirements. - # +value+ existing model to be included in the hash - # +hash+ hash containing configuration generated mainly from +@attributes_finder+ - def hash_or_merge(value, hash) - value.is_a?(Hash) ? value.merge(hash) : { value => hash } - end end end end diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index e41c7e6bf4f..5e56b3d1aa7 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -13,6 +13,10 @@ module Gitlab BUILD_MODELS = %w[Ci::Build commit_status].freeze + IMPORTED_OBJECT_MAX_RETRIES = 5.freeze + + EXISTING_OBJECT_CHECK = %i[milestone milestones label labels].freeze + def self.create(*args) new(*args).create end @@ -22,24 +26,35 @@ module Gitlab @relation_hash = relation_hash.except('id', 'noteable_id') @members_mapper = members_mapper @user = user + @imported_object_retries = 0 end # Creates an object from an actual model with name "relation_sym" with params from # the relation_hash, updating references with new object IDs, mapping users using # the "members_mapper" object, also updating notes if required. def create - set_note_author if @relation_name == :notes + setup_models + + generate_imported_object + end + + private + + def setup_models + if @relation_name == :notes + set_note_author + + # attachment is deprecated and note uploads are handled by Markdown uploader + @relation_hash['attachment'] = nil + end + update_user_references update_project_references reset_ci_tokens if @relation_name == 'Ci::Trigger' @relation_hash['data'].deep_symbolize_keys! if @relation_name == :events && @relation_hash['data'] set_st_diffs if @relation_name == :merge_request_diff - - generate_imported_object end - private - def update_user_references USER_REFERENCES.each do |reference| if @relation_hash[reference] @@ -112,10 +127,14 @@ module Gitlab end def imported_object - imported_object = relation_class.new(parsed_relation_hash) - yield(imported_object) if block_given? - imported_object.importing = true if imported_object.respond_to?(:importing) - imported_object + yield(existing_or_new_object) if block_given? + existing_or_new_object.importing = true if existing_or_new_object.respond_to?(:importing) + existing_or_new_object + rescue ActiveRecord::RecordNotUnique + # as the operation is not atomic, retry in the unlikely scenario an INSERT is + # performed on the same object between the SELECT and the INSERT + @imported_object_retries += 1 + retry if @imported_object_retries < IMPORTED_OBJECT_MAX_RETRIES end def update_note_for_missing_author(author_name) @@ -134,6 +153,20 @@ module Gitlab def set_st_diffs @relation_hash['st_diffs'] = @relation_hash.delete('utf8_st_diffs') end + + def existing_or_new_object + # Only find existing records to avoid mapping tables such as milestones + # Otherwise always create the record, skipping the extra SELECT clause. + @existing_or_new_object ||= begin + if EXISTING_OBJECT_CHECK.include?(@relation_name) + existing_object = relation_class.find_or_initialize_by(parsed_relation_hash.slice('title', 'project_id')) + existing_object.assign_attributes(parsed_relation_hash) + existing_object + else + relation_class.new(parsed_relation_hash) + end + end + end end end end diff --git a/lib/gitlab/import_export/repo_restorer.rb b/lib/gitlab/import_export/repo_restorer.rb index f84de652a57..6d9379acf25 100644 --- a/lib/gitlab/import_export/repo_restorer.rb +++ b/lib/gitlab/import_export/repo_restorer.rb @@ -14,7 +14,7 @@ module Gitlab FileUtils.mkdir_p(path_to_repo) - git_unbundle(repo_path: path_to_repo, bundle_path: @path_to_bundle) + git_unbundle(repo_path: path_to_repo, bundle_path: @path_to_bundle) && repo_restore_hooks rescue => e @shared.error(e) false @@ -29,6 +29,16 @@ module Gitlab def path_to_repo @project.repository.path_to_repo end + + def repo_restore_hooks + return true if wiki? + + git_restore_hooks + end + + def wiki? + @project.class.name == 'ProjectWiki' + end end end end diff --git a/lib/gitlab/import_export/version_checker.rb b/lib/gitlab/import_export/version_checker.rb index abfc694b879..de3fe6d822e 100644 --- a/lib/gitlab/import_export/version_checker.rb +++ b/lib/gitlab/import_export/version_checker.rb @@ -25,7 +25,7 @@ module Gitlab def verify_version!(version) if Gem::Version.new(version) > Gem::Version.new(Gitlab::ImportExport.version) - raise Gitlab::ImportExport::Error("Import version mismatch: Required <= #{Gitlab::ImportExport.version} but was #{version}") + raise Gitlab::ImportExport::Error.new("Import version mismatch: Required <= #{Gitlab::ImportExport.version} but was #{version}") else true end diff --git a/lib/gitlab/incoming_email.rb b/lib/gitlab/incoming_email.rb index 8ce9d32abe0..d7be50bd437 100644 --- a/lib/gitlab/incoming_email.rb +++ b/lib/gitlab/incoming_email.rb @@ -1,7 +1,7 @@ module Gitlab module IncomingEmail class << self - FALLBACK_REPLY_MESSAGE_ID_REGEX = /\Areply\-(.+)@#{Gitlab.config.gitlab.host}\Z/.freeze + FALLBACK_MESSAGE_ID_REGEX = /\Areply\-(.+)@#{Gitlab.config.gitlab.host}\Z/.freeze def enabled? config.enabled && config.address @@ -21,8 +21,8 @@ module Gitlab match[1] end - def key_from_fallback_reply_message_id(message_id) - match = message_id.match(FALLBACK_REPLY_MESSAGE_ID_REGEX) + def key_from_fallback_message_id(mail_id) + match = mail_id.match(FALLBACK_MESSAGE_ID_REGEX) return unless match match[1] diff --git a/lib/gitlab/ldap/access.rb b/lib/gitlab/ldap/access.rb index f2b649e50a2..2f326d00a2f 100644 --- a/lib/gitlab/ldap/access.rb +++ b/lib/gitlab/ldap/access.rb @@ -25,7 +25,7 @@ module Gitlab end end - def initialize(user, adapter=nil) + def initialize(user, adapter = nil) @adapter = adapter @user = user @provider = user.ldap_identity.provider diff --git a/lib/gitlab/ldap/adapter.rb b/lib/gitlab/ldap/adapter.rb index df65179bfea..9a5bcfb5c9b 100644 --- a/lib/gitlab/ldap/adapter.rb +++ b/lib/gitlab/ldap/adapter.rb @@ -13,7 +13,7 @@ module Gitlab Gitlab::LDAP::Config.new(provider) end - def initialize(provider, ldap=nil) + def initialize(provider, ldap = nil) @provider = provider @ldap = ldap || Net::LDAP.new(config.adapter_options) end diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb index 49f702f91f6..41fcd971c22 100644 --- a/lib/gitlab/metrics.rb +++ b/lib/gitlab/metrics.rb @@ -124,6 +124,11 @@ module Gitlab trans.action = action if trans end + # Returns the prefix to use for the name of a series. + def self.series_prefix + @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_' + end + # When enabled this should be set before being used as the usual pattern # "@foo ||= bar" is _not_ thread-safe. if enabled? @@ -136,8 +141,7 @@ module Gitlab end end - private - + # Allow access from other metrics related middlewares def self.current_transaction Transaction.current end diff --git a/lib/gitlab/metrics/instrumentation.rb b/lib/gitlab/metrics/instrumentation.rb index dcec7543c13..4b7a791e497 100644 --- a/lib/gitlab/metrics/instrumentation.rb +++ b/lib/gitlab/metrics/instrumentation.rb @@ -9,14 +9,17 @@ module Gitlab # # Gitlab::Metrics::Instrumentation.instrument_method(User, :by_login) module Instrumentation - SERIES = 'method_calls' - PROXY_IVAR = :@__gitlab_instrumentation_proxy def self.configure yield self end + # Returns the name of the series to use for storing method calls. + def self.series + @series ||= "#{Metrics.series_prefix}method_calls" + end + # Instruments a class method. # # mod - The module to instrument as a Module/Class. @@ -141,15 +144,15 @@ module Gitlab # generated method _only_ accepts regular arguments if the underlying # method also accepts them. if method.arity == 0 - args_signature = '&block' + args_signature = '' else - args_signature = '*args, &block' + args_signature = '*args' end proxy_module.class_eval <<-EOF, __FILE__, __LINE__ + 1 def #{name}(#{args_signature}) if trans = Gitlab::Metrics::Instrumentation.transaction - trans.measure_method(#{label.inspect}) { super } + trans.method_call_for(#{label.to_sym.inspect}).measure { super } else super end diff --git a/lib/gitlab/metrics/method_call.rb b/lib/gitlab/metrics/method_call.rb index c048fe20ba7..d3465e5ec19 100644 --- a/lib/gitlab/metrics/method_call.rb +++ b/lib/gitlab/metrics/method_call.rb @@ -11,8 +11,8 @@ module Gitlab def initialize(name, series) @name = name @series = series - @real_time = 0.0 - @cpu_time = 0.0 + @real_time = 0 + @cpu_time = 0 @call_count = 0 end diff --git a/lib/gitlab/metrics/system.rb b/lib/gitlab/metrics/system.rb index 82c18bb108b..287b7a83547 100644 --- a/lib/gitlab/metrics/system.rb +++ b/lib/gitlab/metrics/system.rb @@ -35,12 +35,12 @@ module Gitlab if Process.const_defined?(:CLOCK_THREAD_CPUTIME_ID) def self.cpu_time Process. - clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID, :millisecond).to_f + clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID, :millisecond) end else def self.cpu_time Process. - clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :millisecond).to_f + clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :millisecond) end end @@ -48,14 +48,14 @@ module Gitlab # # Returns the time as a Float. def self.real_time(precision = :millisecond) - Process.clock_gettime(Process::CLOCK_REALTIME, precision).to_f + Process.clock_gettime(Process::CLOCK_REALTIME, precision) end # Returns the current monotonic clock time in a given precision. # # Returns the time as a Float. def self.monotonic_time(precision = :millisecond) - Process.clock_gettime(Process::CLOCK_MONOTONIC, precision).to_f + Process.clock_gettime(Process::CLOCK_MONOTONIC, precision) end end end diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb index bded245da43..968f3218950 100644 --- a/lib/gitlab/metrics/transaction.rb +++ b/lib/gitlab/metrics/transaction.rb @@ -52,23 +52,16 @@ module Gitlab end def add_metric(series, values, tags = {}) - @metrics << Metric.new("#{series_prefix}#{series}", values, tags) + @metrics << Metric.new("#{Metrics.series_prefix}#{series}", values, tags) end - # Measures the time it takes to execute a method. - # - # Multiple calls to the same method add up to the total runtime of the - # method. - # - # name - The full name of the method to measure (e.g. `User#sign_in`). - def measure_method(name, &block) - unless @methods[name] - series = "#{series_prefix}#{Instrumentation::SERIES}" - - @methods[name] = MethodCall.new(name, series) + # Returns a MethodCall object for the given name. + def method_call_for(name) + unless method = @methods[name] + @methods[name] = method = MethodCall.new(name, Instrumentation.series) end - @methods[name].measure(&block) + method end def increment(name, value) @@ -115,14 +108,6 @@ module Gitlab Metrics.submit_metrics(submit_hashes) end - - def sidekiq? - Sidekiq.server? - end - - def series_prefix - sidekiq? ? 'sidekiq_' : 'rails_' - end end end end diff --git a/lib/gitlab/popen.rb b/lib/gitlab/popen.rb index 43e07e09160..ca23ccef25b 100644 --- a/lib/gitlab/popen.rb +++ b/lib/gitlab/popen.rb @@ -5,7 +5,7 @@ module Gitlab module Popen extend self - def popen(cmd, path=nil) + def popen(cmd, path = nil) unless cmd.is_a?(Array) raise "System commands must be given as an array of strings" end diff --git a/lib/gitlab/redis.rb b/lib/gitlab/redis.rb index 40766f35f77..1f92986ec9a 100644 --- a/lib/gitlab/redis.rb +++ b/lib/gitlab/redis.rb @@ -37,7 +37,7 @@ module Gitlab redis_config_hash end - def initialize(rails_env=nil) + def initialize(rails_env = nil) rails_env ||= Rails.env config_file = File.expand_path('../../../config/resque.yml', __FILE__) diff --git a/lib/gitlab/request_profiler.rb b/lib/gitlab/request_profiler.rb new file mode 100644 index 00000000000..8130e55351e --- /dev/null +++ b/lib/gitlab/request_profiler.rb @@ -0,0 +1,19 @@ +require 'fileutils' + +module Gitlab + module RequestProfiler + PROFILES_DIR = "#{Gitlab.config.shared.path}/tmp/requests_profiles" + + def profile_token + Rails.cache.fetch('profile-token') do + Devise.friendly_token + end + end + module_function :profile_token + + def remove_all_profiles + FileUtils.rm_rf(PROFILES_DIR) + end + module_function :remove_all_profiles + end +end diff --git a/lib/gitlab/request_profiler/middleware.rb b/lib/gitlab/request_profiler/middleware.rb new file mode 100644 index 00000000000..4e787dc0656 --- /dev/null +++ b/lib/gitlab/request_profiler/middleware.rb @@ -0,0 +1,54 @@ +require 'ruby-prof' +require 'gitlab/request_profiler' + +module Gitlab + module RequestProfiler + class Middleware + def initialize(app) + @app = app + end + + def call(env) + if profile?(env) + call_with_profiling(env) + else + @app.call(env) + end + end + + def profile?(env) + header_token = env['HTTP_X_PROFILE_TOKEN'] + return unless header_token.present? + + profile_token = RequestProfiler.profile_token + return unless profile_token.present? + + header_token == profile_token + end + + def call_with_profiling(env) + ret = nil + result = RubyProf::Profile.profile do + ret = catch(:warden) do + @app.call(env) + end + end + + printer = RubyProf::CallStackPrinter.new(result) + file_name = "#{env['PATH_INFO'].tr('/', '|')}_#{Time.current.to_i}.html" + file_path = "#{PROFILES_DIR}/#{file_name}" + + FileUtils.mkdir_p(PROFILES_DIR) + File.open(file_path, 'wb') do |file| + printer.print(file) + end + + if ret.is_a?(Array) + ret + else + throw(:warden, ret) + end + end + end + end +end diff --git a/lib/gitlab/request_profiler/profile.rb b/lib/gitlab/request_profiler/profile.rb new file mode 100644 index 00000000000..f89d56903ef --- /dev/null +++ b/lib/gitlab/request_profiler/profile.rb @@ -0,0 +1,43 @@ +module Gitlab + module RequestProfiler + class Profile + attr_reader :name, :time, :request_path + + alias_method :to_param, :name + + def self.all + Dir["#{PROFILES_DIR}/*.html"].map do |path| + new(File.basename(path)) + end + end + + def self.find(name) + name_dup = name.dup + name_dup << '.html' unless name.end_with?('.html') + + file_path = "#{PROFILES_DIR}/#{name_dup}" + return unless File.exist?(file_path) + + new(name_dup) + end + + def initialize(name) + @name = name + + set_attributes + end + + def content + File.read("#{PROFILES_DIR}/#{name}") + end + + private + + def set_attributes + _, path, timestamp = name.split(/(.*)_(\d+)\.html$/) + @request_path = path.tr('|', '/') + @time = Time.at(timestamp.to_i).utc + end + end + end +end diff --git a/lib/gitlab/sidekiq_middleware/request_store_middleware.rb b/lib/gitlab/sidekiq_middleware/request_store_middleware.rb new file mode 100644 index 00000000000..b1fa0e3cb4e --- /dev/null +++ b/lib/gitlab/sidekiq_middleware/request_store_middleware.rb @@ -0,0 +1,13 @@ +module Gitlab + module SidekiqMiddleware + class RequestStoreMiddleware + def call(worker, job, queue) + RequestStore.begin! + yield + ensure + RequestStore.end! + RequestStore.clear! + end + end + end +end diff --git a/lib/gitlab/themes.rb b/lib/gitlab/themes.rb index 83f91de810c..d4020af76f9 100644 --- a/lib/gitlab/themes.rb +++ b/lib/gitlab/themes.rb @@ -2,6 +2,8 @@ module Gitlab # Module containing GitLab's application theme definitions and helper methods # for accessing them. module Themes + extend self + # Theme ID used when no `default_theme` configuration setting is provided. APPLICATION_DEFAULT = 2 @@ -22,7 +24,7 @@ module Gitlab # classes that might be applied to the `body` element # # Returns a String - def self.body_classes + def body_classes THEMES.collect(&:css_class).uniq.join(' ') end @@ -33,26 +35,26 @@ module Gitlab # id - Integer ID # # Returns a Theme - def self.by_id(id) + def by_id(id) THEMES.detect { |t| t.id == id } || default end # Returns the number of defined Themes - def self.count + def count THEMES.size end # Get the default Theme # # Returns a Theme - def self.default + def default by_id(default_id) end # Iterate through each Theme # # Yields the Theme object - def self.each(&block) + def each(&block) THEMES.each(&block) end @@ -61,7 +63,7 @@ module Gitlab # user - User record # # Returns a Theme - def self.for_user(user) + def for_user(user) if user by_id(user.theme_id) else @@ -71,7 +73,7 @@ module Gitlab private - def self.default_id + def default_id id = Gitlab.config.gitlab.default_theme.to_i # Prevent an invalid configuration setting from causing an infinite loop diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index c0f85e9b3a8..c55a7fc4d3d 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -29,8 +29,11 @@ module Gitlab def can_push_to_branch?(ref) return false unless user - if project.protected_branch?(ref) && !project.developers_can_push_to_protected_branch?(ref) - user.can?(:push_code_to_protected_branches, project) + if project.protected_branch?(ref) + return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user) + + access_levels = project.protected_branches.matching(ref).map(&:push_access_level) + access_levels.any? { |access_level| access_level.check_access(user) } else user.can?(:push_code, project) end @@ -39,8 +42,9 @@ module Gitlab def can_merge_to_branch?(ref) return false unless user - if project.protected_branch?(ref) && !project.developers_can_merge_to_protected_branch?(ref) - user.can?(:push_code_to_protected_branches, project) + if project.protected_branch?(ref) + access_levels = project.protected_branches.matching(ref).map(&:merge_access_level) + access_levels.any? { |access_level| access_level.check_access(user) } else user.can?(:push_code, project) end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 6aeb49c0219..c6826a09bd2 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -4,6 +4,7 @@ require 'json' module Gitlab class Workhorse SEND_DATA_HEADER = 'Gitlab-Workhorse-Send-Data' + VERSION_FILE = 'GITLAB_WORKHORSE_VERSION' class << self def git_http_ok(repository, user) @@ -75,6 +76,11 @@ module Gitlab ] end + def version + path = Rails.root.join(VERSION_FILE) + path.readable? ? path.read.chomp : 'unknown' + end + protected def encode(hash) diff --git a/lib/repository_cache.rb b/lib/repository_cache.rb index 8ddc3511293..068a95790c0 100644 --- a/lib/repository_cache.rb +++ b/lib/repository_cache.rb @@ -1,14 +1,15 @@ # Interface to the Redis-backed cache store used by the Repository model class RepositoryCache - attr_reader :namespace, :backend + attr_reader :namespace, :backend, :project_id - def initialize(namespace, backend = Rails.cache) + def initialize(namespace, project_id, backend = Rails.cache) @namespace = namespace @backend = backend + @project_id = project_id end def cache_key(type) - "#{type}:#{namespace}" + "#{type}:#{namespace}:#{project_id}" end def expire(key) diff --git a/lib/rouge/formatters/html_gitlab.rb b/lib/rouge/formatters/html_gitlab.rb index f818dc78d34..4edfd015074 100644 --- a/lib/rouge/formatters/html_gitlab.rb +++ b/lib/rouge/formatters/html_gitlab.rb @@ -18,7 +18,7 @@ module Rouge is_first = false yield %(<span id="LC#{@line_number}" class="line">) - line.each { |token, value| yield span(token, value) } + line.each { |token, value| yield span(token, value.chomp) } yield %(</span>) @line_number += 1 diff --git a/lib/support/nginx/gitlab b/lib/support/nginx/gitlab index 4a4892a2e07..d521de28e8a 100644 --- a/lib/support/nginx/gitlab +++ b/lib/support/nginx/gitlab @@ -49,12 +49,7 @@ server { proxy_http_version 1.1; - ## By overwriting Host and clearing X-Forwarded-Host we ensure that - ## internal HTTP redirects generated by GitLab always send users to - ## YOUR_SERVER_FQDN. - proxy_set_header Host YOUR_SERVER_FQDN; - proxy_set_header X-Forwarded-Host ""; - + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/lib/support/nginx/gitlab-ssl b/lib/support/nginx/gitlab-ssl index 0b93d7f292f..bf014b56cf6 100644 --- a/lib/support/nginx/gitlab-ssl +++ b/lib/support/nginx/gitlab-ssl @@ -93,12 +93,7 @@ server { proxy_http_version 1.1; - ## By overwriting Host and clearing X-Forwarded-Host we ensure that - ## internal HTTP redirects generated by GitLab always send users to - ## YOUR_SERVER_FQDN. - proxy_set_header Host YOUR_SERVER_FQDN; - proxy_set_header X-Forwarded-Host ""; - + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Ssl on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/lib/tasks/downtime_check.rake b/lib/tasks/downtime_check.rake index 30a2e9be5ce..afe5d42910c 100644 --- a/lib/tasks/downtime_check.rake +++ b/lib/tasks/downtime_check.rake @@ -1,26 +1,12 @@ desc 'Checks if migrations in a branch require downtime' task downtime_check: :environment do - # First we'll want to make sure we're comparing with the right upstream - # repository/branch. - current_branch = `git rev-parse --abbrev-ref HEAD`.strip - - # Either the developer ran this task directly on the master branch, or they're - # making changes directly on the master branch. - if current_branch == 'master' - if defined?(Gitlab::License) - repo = 'gitlab-ee' - else - repo = 'gitlab-ce' - end - - `git fetch https://gitlab.com/gitlab-org/#{repo}.git --depth 1` - - compare_with = 'FETCH_HEAD' - # The developer is working on a different branch, in this case we can just - # compare with the master branch. + if defined?(Gitlab::License) + repo = 'gitlab-ee' else - compare_with = 'master' + repo = 'gitlab-ce' end - Rake::Task['gitlab:db:downtime_check'].invoke(compare_with) + `git fetch https://gitlab.com/gitlab-org/#{repo}.git --depth 1` + + Rake::Task['gitlab:db:downtime_check'].invoke('FETCH_HEAD') end diff --git a/lib/tasks/gitlab/bulk_add_permission.rake b/lib/tasks/gitlab/bulk_add_permission.rake index 5dbf7d61e06..83dd870fa31 100644 --- a/lib/tasks/gitlab/bulk_add_permission.rake +++ b/lib/tasks/gitlab/bulk_add_permission.rake @@ -4,13 +4,13 @@ namespace :gitlab do task all_users_to_all_projects: :environment do |t, args| user_ids = User.where(admin: false).pluck(:id) admin_ids = User.where(admin: true).pluck(:id) - projects_ids = Project.pluck(:id) + project_ids = Project.pluck(:id) - puts "Importing #{user_ids.size} users into #{projects_ids.size} projects" - ProjectMember.add_users_into_projects(projects_ids, user_ids, ProjectMember::DEVELOPER) + puts "Importing #{user_ids.size} users into #{project_ids.size} projects" + ProjectMember.add_users_to_projects(project_ids, user_ids, ProjectMember::DEVELOPER) - puts "Importing #{admin_ids.size} admins into #{projects_ids.size} projects" - ProjectMember.add_users_into_projects(projects_ids, admin_ids, ProjectMember::MASTER) + puts "Importing #{admin_ids.size} admins into #{project_ids.size} projects" + ProjectMember.add_users_to_projects(project_ids, admin_ids, ProjectMember::MASTER) end desc "GitLab | Add a specific user to all projects (as a developer)" @@ -18,7 +18,7 @@ namespace :gitlab do user = User.find_by(email: args.email) project_ids = Project.pluck(:id) puts "Importing #{user.email} users into #{project_ids.size} projects" - ProjectMember.add_users_into_projects(project_ids, Array.wrap(user.id), ProjectMember::DEVELOPER) + ProjectMember.add_users_to_projects(project_ids, Array.wrap(user.id), ProjectMember::DEVELOPER) end desc "GitLab | Add all users to all groups (admin users are added as owners)" diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake index 0ec19e1a625..7c96bc864ce 100644 --- a/lib/tasks/gitlab/db.rake +++ b/lib/tasks/gitlab/db.rake @@ -25,6 +25,10 @@ namespace :gitlab do desc 'Drop all tables' task :drop_tables => :environment do connection = ActiveRecord::Base.connection + + # If MySQL, turn off foreign key checks + connection.execute('SET FOREIGN_KEY_CHECKS=0') if Gitlab::Database.mysql? + tables = connection.tables tables.delete 'schema_migrations' # Truncate schema_migrations to ensure migrations re-run @@ -35,6 +39,9 @@ namespace :gitlab do # MySQL: http://dev.mysql.com/doc/refman/5.7/en/drop-table.html # Add `IF EXISTS` because cascade could have already deleted a table. tables.each { |t| connection.execute("DROP TABLE IF EXISTS #{connection.quote_table_name(t)} CASCADE") } + + # If MySQL, re-enable foreign key checks + connection.execute('SET FOREIGN_KEY_CHECKS=1') if Gitlab::Database.mysql? end desc 'Configures the database by running migrate, or by loading the schema and seeding if needed' diff --git a/lib/tasks/gitlab/shell.rake b/lib/tasks/gitlab/shell.rake index c85ebdf8619..ba93945bd03 100644 --- a/lib/tasks/gitlab/shell.rake +++ b/lib/tasks/gitlab/shell.rake @@ -5,7 +5,8 @@ namespace :gitlab do warn_user_is_not_gitlab default_version = Gitlab::Shell.version_required - args.with_defaults(tag: 'v' + default_version, repo: "https://gitlab.com/gitlab-org/gitlab-shell.git") + default_version_tag = 'v' + default_version + args.with_defaults(tag: default_version_tag, repo: "https://gitlab.com/gitlab-org/gitlab-shell.git") user = Gitlab.config.gitlab.user home_dir = Rails.env.test? ? Rails.root.join('tmp/tests') : Gitlab.config.gitlab.user_home @@ -15,7 +16,12 @@ namespace :gitlab do target_dir = Gitlab.config.gitlab_shell.path # Clone if needed - unless File.directory?(target_dir) + if File.directory?(target_dir) + Dir.chdir(target_dir) do + system(*%W(Gitlab.config.git.bin_path} fetch --tags --quiet)) + system(*%W(Gitlab.config.git.bin_path} checkout --quiet #{default_version_tag})) + end + else system(*%W(#{Gitlab.config.git.bin_path} clone -- #{args.repo} #{target_dir})) end diff --git a/lib/tasks/gitlab/web_hook.rake b/lib/tasks/gitlab/web_hook.rake index f467cc0ee29..49530e7a372 100644 --- a/lib/tasks/gitlab/web_hook.rake +++ b/lib/tasks/gitlab/web_hook.rake @@ -26,10 +26,10 @@ namespace :gitlab do namespace_path = ENV['NAMESPACE'] projects = find_projects(namespace_path) - projects_ids = projects.pluck(:id) + project_ids = projects.pluck(:id) puts "Removing webhooks with the url '#{web_hook_url}' ... " - count = WebHook.where(url: web_hook_url, project_id: projects_ids, type: 'ProjectHook').delete_all + count = WebHook.where(url: web_hook_url, project_id: project_ids, type: 'ProjectHook').delete_all puts "#{count} webhooks were removed." end diff --git a/lib/tasks/test.rake b/lib/tasks/test.rake index 21c0e5f1d41..d3dcbd2c29b 100644 --- a/lib/tasks/test.rake +++ b/lib/tasks/test.rake @@ -7,5 +7,5 @@ end unless Rails.env.production? desc "GitLab | Run all tests on CI with simplecov" - task test_ci: [:rubocop, :brakeman, 'teaspoon', :spinach, :spec] + task test_ci: [:rubocop, :brakeman, :teaspoon, :spinach, :spec] end diff --git a/public/404.html b/public/404.html index 4862770cc2a..92b7f4da0b9 100644 --- a/public/404.html +++ b/public/404.html @@ -1,55 +1,65 @@ <!DOCTYPE html> <html> <head> + <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport"> <title>The page you're looking for could not be found (404)</title> <style> - body { - color: #666; - text-align: center; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; - margin: auto; - font-size: 14px; - } + body { + color: #666; + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 14px; + } - h1 { - font-size: 56px; - line-height: 100px; - font-weight: normal; - color: #456; - } + h1 { + font-size: 56px; + line-height: 100px; + font-weight: normal; + color: #456; + } - h2 { - font-size: 24px; - color: #666; - line-height: 1.5em; - } + h2 { + font-size: 24px; + color: #666; + line-height: 1.5em; + } - h3 { - color: #456; - font-size: 20px; - font-weight: normal; - line-height: 28px; - } + h3 { + color: #456; + font-size: 20px; + font-weight: normal; + line-height: 28px; + } - hr { - margin: 18px 0; - border: 0; - border-top: 1px solid #EEE; - border-bottom: 1px solid white; - } + hr { + max-width: 800px; + margin: 18px auto; + border: 0; + border-top: 1px solid #EEE; + border-bottom: 1px solid white; + } + + img { + max-width: 40vw; + } + + .container { + margin: auto 20px; + } </style> </head> <body> <h1> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" /><br /> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /><br /> 404 </h1> - <h3>The page you're looking for could not be found.</h3> - <hr/> - <p>Make sure the address is correct and that the page hasn't moved.</p> - <p>Please contact your GitLab administrator if you think this is a mistake.</p> + <div class="container"> + <h3>The page you're looking for could not be found.</h3> + <hr /> + <p>Make sure the address is correct and that the page hasn't moved.</p> + <p>Please contact your GitLab administrator if you think this is a mistake.</p> + </div> </body> </html> diff --git a/public/422.html b/public/422.html index 055b0bde165..f625f8a33b7 100644 --- a/public/422.html +++ b/public/422.html @@ -1,55 +1,65 @@ <!DOCTYPE html> <html> <head> + <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport"> <title>The change you requested was rejected (422)</title> <style> body { color: #666; - text-align: center; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; - margin: auto; - font-size: 14px; - } + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 14px; + } - h1 { - font-size: 56px; - line-height: 100px; - font-weight: normal; - color: #456; - } + h1 { + font-size: 56px; + line-height: 100px; + font-weight: normal; + color: #456; + } - h2 { - font-size: 24px; - color: #666; - line-height: 1.5em; - } + h2 { + font-size: 24px; + color: #666; + line-height: 1.5em; + } + + h3 { + color: #456; + font-size: 20px; + font-weight: normal; + line-height: 28px; + } + + hr { + max-width: 800px; + margin: 18px auto; + border: 0; + border-top: 1px solid #EEE; + border-bottom: 1px solid white; + } - h3 { - color: #456; - font-size: 20px; - font-weight: normal; - line-height: 28px; - } + img { + max-width: 40vw; + } - hr { - margin: 18px 0; - border: 0; - border-top: 1px solid #EEE; - border-bottom: 1px solid white; - } - </style> + .container { + margin: auto 20px; + } + </style> </head> <body> <h1> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" /><br /> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /><br /> 422 </h1> - <h3>The change you requested was rejected.</h3> - <hr /> - <p>Make sure you have access to the thing you tried to change.</p> - <p>Please contact your GitLab administrator if you think this is a mistake.</p> + <div class="container"> + <h3>The change you requested was rejected.</h3> + <hr /> + <p>Make sure you have access to the thing you tried to change.</p> + <p>Please contact your GitLab administrator if you think this is a mistake.</p> + </div> </body> </html> diff --git a/public/500.html b/public/500.html index 3d59d1392f5..d76c66ba92a 100644 --- a/public/500.html +++ b/public/500.html @@ -1,54 +1,65 @@ <!DOCTYPE html> <html> <head> + <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport"> <title>Something went wrong (500)</title> <style> - body { - color: #666; - text-align: center; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; - margin: auto; - font-size: 14px; - } + body { + color: #666; + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 14px; + } - h1 { - font-size: 56px; - line-height: 100px; - font-weight: normal; - color: #456; - } + h1 { + font-size: 56px; + line-height: 100px; + font-weight: normal; + color: #456; + } - h2 { - font-size: 24px; - color: #666; - line-height: 1.5em; - } + h2 { + font-size: 24px; + color: #666; + line-height: 1.5em; + } - h3 { - color: #456; - font-size: 20px; - font-weight: normal; - line-height: 28px; - } + h3 { + color: #456; + font-size: 20px; + font-weight: normal; + line-height: 28px; + } - hr { - margin: 18px 0; + hr { + max-width: 800px; + margin: 18px auto; border: 0; border-top: 1px solid #EEE; border-bottom: 1px solid white; } + + img { + max-width: 40vw; + } + + .container { + margin: auto 20px; + } </style> </head> + <body> <h1> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" /><br /> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /><br /> 500 </h1> - <h3>Whoops, something went wrong on our end.</h3> - <hr/> - <p>Try refreshing the page, or going back and attempting the action again.</p> - <p>Please contact your GitLab administrator if this problem persists.</p> + <div class="container"> + <h3>Whoops, something went wrong on our end.</h3> + <hr /> + <p>Try refreshing the page, or going back and attempting the action again.</p> + <p>Please contact your GitLab administrator if this problem persists.</p> + </div> </body> </html> diff --git a/public/502.html b/public/502.html index 67dfd8a2743..1a3c7efc769 100644 --- a/public/502.html +++ b/public/502.html @@ -1,14 +1,13 @@ <!DOCTYPE html> <html> <head> + <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport"> <title>GitLab is not responding (502)</title> <style> body { color: #666; text-align: center; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; margin: auto; font-size: 14px; } @@ -34,21 +33,33 @@ } hr { - margin: 18px 0; + max-width: 800px; + margin: 18px auto; border: 0; border-top: 1px solid #EEE; border-bottom: 1px solid white; } + + img { + max-width: 40vw; + } + + .container { + margin: auto 20px; + } </style> </head> + <body> <h1> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" /><br /> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /><br /> 502 </h1> - <h3>Whoops, GitLab is taking too much time to respond.</h3> - <hr/> - <p>Try refreshing the page, or going back and attempting the action again.</p> - <p>Please contact your GitLab administrator if this problem persists.</p> + <div class="container"> + <h3>Whoops, GitLab is taking too much time to respond.</h3> + <hr /> + <p>Try refreshing the page, or going back and attempting the action again.</p> + <p>Please contact your GitLab administrator if this problem persists.</p> + </div> </body> </html> diff --git a/public/503.html b/public/503.html index 6ab1185658d..c1c4e3ffdb8 100644 --- a/public/503.html +++ b/public/503.html @@ -1,14 +1,13 @@ <!DOCTYPE html> <html> <head> + <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport"> <title>GitLab is not responding (503)</title> <style> body { color: #666; text-align: center; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; margin: auto; font-size: 14px; } @@ -34,21 +33,33 @@ } hr { - margin: 18px 0; + max-width: 800px; + margin: 18px auto; border: 0; border-top: 1px solid #EEE; border-bottom: 1px solid white; } + + img { + max-width: 40vw; + } + + .container { + margin: auto 20px; + } </style> </head> + <body> <h1> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo"/><br /> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /><br /> 503 </h1> - <h3>Whoops, GitLab is currently unavailable.</h3> - <hr/> - <p>Try refreshing the page, or going back and attempting the action again.</p> - <p>Please contact your GitLab administrator if this problem persists.</p> + <div class="container"> + <h3>Whoops, GitLab is currently unavailable.</h3> + <hr /> + <p>Try refreshing the page, or going back and attempting the action again.</p> + <p>Please contact your GitLab administrator if this problem persists.</p> + </div> </body> </html> diff --git a/public/deploy.html b/public/deploy.html index 48976dacf41..142472b6c35 100644 --- a/public/deploy.html +++ b/public/deploy.html @@ -1,54 +1,64 @@ <!DOCTYPE html> <html> - <head> - <title>Deploy in progress</title> - <style> - body { - color: #666; - text-align: center; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; - margin: auto; - font-size: 14px; - } +<head> + <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport"> + <title>Deploy in progress</title> + <style> + body { + color: #666; + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 14px; + } - h1 { - font-size: 56px; - line-height: 100px; - font-weight: normal; - color: #456; - } + h1 { + font-size: 56px; + line-height: 100px; + font-weight: normal; + color: #456; + } - h2 { - font-size: 24px; - color: #666; - line-height: 1.5em; - } + h2 { + font-size: 24px; + color: #666; + line-height: 1.5em; + } - h3 { - color: #456; - font-size: 20px; - font-weight: normal; - line-height: 28px; - } + h3 { + color: #456; + font-size: 20px; + font-weight: normal; + line-height: 28px; + } - hr { - margin: 18px 0; - border: 0; - border-top: 1px solid #EEE; - border-bottom: 1px solid white; - } - </style> - </head> + hr { + max-width: 800px; + margin: 18px auto; + border: 0; + border-top: 1px solid #EEE; + border-bottom: 1px solid white; + } - <body> - <h1> - <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" /><br /> - Deploy in progress - </h1> + img { + max-width: 40vw; + } + + .container { + margin: auto 20px; + } + </style> +</head> + +<body> + <h1> + <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEwIiBoZWlnaHQ9IjIxMCIgdmlld0JveD0iMCAwIDIxMCAyMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTVsMzguNjQtMTE4LjkyMWgtNzcuMjhsMzguNjQgMTE4LjkyMXoiIGZpbGw9IiNlMjQzMjkiLz4KICA8cGF0aCBkPSJNMTA1LjA2MTQgMjAzLjY1NDhsLTM4LjY0LTExOC45MjFoLTU0LjE1M2w5Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTIuMjY4NSA4NC43MzQxbC0xMS43NDIgMzYuMTM5Yy0xLjA3MSAzLjI5Ni4xMDIgNi45MDcgMi45MDYgOC45NDRsMTAxLjYyOSA3My44MzgtOTIuNzkzLTExOC45MjF6IiBmaWxsPSIjZmNhMzI2Ii8+CiAgPHBhdGggZD0iTTEyLjI2ODUgODQuNzM0Mmg1NC4xNTNsLTIzLjI3My03MS42MjVjLTEuMTk3LTMuNjg2LTYuNDExLTMuNjg1LTcuNjA4IDBsLTIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+CiAgPHBhdGggZD0iTTEwNS4wNjE0IDIwMy42NTQ4bDM4LjY0LTExOC45MjFoNTQuMTUzbC05Mi43OTMgMTE4LjkyMXoiIGZpbGw9IiNmYzZkMjYiLz4KICA8cGF0aCBkPSJNMTk3Ljg1NDQgODQuNzM0MWwxMS43NDIgMzYuMTM5YzEuMDcxIDMuMjk2LS4xMDIgNi45MDctMi45MDYgOC45NDRsLTEwMS42MjkgNzMuODM4IDkyLjc5My0xMTguOTIxeiIgZmlsbD0iI2ZjYTMyNiIvPgogIDxwYXRoIGQ9Ik0xOTcuODU0NCA4NC43MzQyaC01NC4xNTNsMjMuMjczLTcxLjYyNWMxLjE5Ny0zLjY4NiA2LjQxMS0zLjY4NSA3LjYwOCAwbDIzLjI3MiA3MS42MjV6IiBmaWxsPSIjZTI0MzI5Ii8+Cjwvc3ZnPgo=" alt="GitLab Logo" /><br /> + Deploy in progress + </h1> + <div class="container"> <h3>Please try again in a few minutes.</h3> - <hr/> + <hr /> <p>Please contact your GitLab administrator if this problem persists.</p> - </body> + </div> +</body> </html> diff --git a/scripts/merge-simplecov b/scripts/merge-simplecov new file mode 100755 index 00000000000..65f93f8830b --- /dev/null +++ b/scripts/merge-simplecov @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby + +require_relative '../spec/simplecov_env' +SimpleCovEnv.configure_profile + +module SimpleCov + module ResultMerger + class << self + def resultset_files + Dir.glob(File.join(SimpleCov.coverage_path, '*', '.resultset.json')) + end + + def resultset_hashes + resultset_files.map do |path| + begin + JSON.parse(File.read(path)) + rescue + {} + end + end + end + + def resultset + resultset_hashes.reduce({}, :merge) + end + end + end +end + +SimpleCov::ResultMerger.merged_result.format! diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb index 60c654f622d..ed0b7f9e240 100644 --- a/spec/controllers/autocomplete_controller_spec.rb +++ b/spec/controllers/autocomplete_controller_spec.rb @@ -163,4 +163,17 @@ describe AutocompleteController do expect(body.collect { |u| u['id'] }).not_to include(99999) end end + + context 'skip_users parameter included' do + before { sign_in(user) } + + it 'skips the user IDs passed' do + get(:users, skip_users: [user, user2].map(&:id)) + + other_user_ids = [non_member, project.owner, project.creator].map(&:id) + response_user_ids = JSON.parse(response.body).map { |user| user['id'] } + + expect(response_user_ids).to contain_exactly(*other_user_ids) + end + end end diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index 3001d32e719..940019b708b 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -24,15 +24,6 @@ describe Projects::CommitController do get :show, params.merge(extra_params) end - let(:project) { create(:project) } - - before do - user = create(:user) - project.team << [user, :master] - - sign_in(user) - end - context 'with valid id' do it 'responds with 200' do go(id: commit.id) @@ -92,7 +83,7 @@ describe Projects::CommitController do let(:format) { :diff } it "should really only be a git diff" do - go(id: commit.id, format: format) + go(id: '66eceea0db202bb39c4e445e8ca28689645366c5', format: format) expect(response.body).to start_with("diff --git") end @@ -101,8 +92,9 @@ describe Projects::CommitController do go(id: '66eceea0db202bb39c4e445e8ca28689645366c5', format: format, w: 1) expect(response.body).to start_with("diff --git") - # without whitespace option, there are more than 2 diff_splits - diff_splits = assigns(:diffs).first.diff.split("\n") + + # without whitespace option, there are more than 2 diff_splits for other formats + diff_splits = assigns(:diffs).diff_files.first.diff.diff.split("\n") expect(diff_splits.length).to be <= 2 end end @@ -275,9 +267,9 @@ describe Projects::CommitController do end it 'only renders the diffs for the path given' do - expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs, diff_refs, project| - expect(diffs.map(&:new_path)).to contain_exactly(existing_path) - meth.call(diffs, diff_refs, project) + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) end diff_for_path(id: commit.id, old_path: existing_path, new_path: existing_path) diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb index 4058d5e2453..ed4cc36de58 100644 --- a/spec/controllers/projects/compare_controller_spec.rb +++ b/spec/controllers/projects/compare_controller_spec.rb @@ -19,7 +19,7 @@ describe Projects::CompareController do to: ref_to) expect(response).to be_success - expect(assigns(:diffs).first).not_to be_nil + expect(assigns(:diffs).diff_files.first).not_to be_nil expect(assigns(:commits).length).to be >= 1 end @@ -32,10 +32,11 @@ describe Projects::CompareController do w: 1) expect(response).to be_success - expect(assigns(:diffs).first).not_to be_nil + diff_file = assigns(:diffs).diff_files.first + expect(diff_file).not_to be_nil expect(assigns(:commits).length).to be >= 1 # without whitespace option, there are more than 2 diff_splits - diff_splits = assigns(:diffs).first.diff.split("\n") + diff_splits = diff_file.diff.diff.split("\n") expect(diff_splits.length).to be <= 2 end @@ -48,7 +49,7 @@ describe Projects::CompareController do to: ref_to) expect(response).to be_success - expect(assigns(:diffs).to_a).to eq([]) + expect(assigns(:diffs).diff_files.to_a).to eq([]) expect(assigns(:commits)).to eq([]) end @@ -87,9 +88,9 @@ describe Projects::CompareController do end it 'only renders the diffs for the path given' do - expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs, diff_refs, project| - expect(diffs.map(&:new_path)).to contain_exactly(existing_path) - meth.call(diffs, diff_refs, project) + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) end diff_for_path(from: ref_from, to: ref_to, old_path: existing_path, new_path: existing_path) diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb new file mode 100644 index 00000000000..768105cae95 --- /dev/null +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe Projects::EnvironmentsController do + let(:environment) { create(:environment) } + let(:project) { environment.project } + let(:user) { create(:user) } + + before do + project.team << [user, :master] + + sign_in(user) + end + + describe 'GET show' do + context 'with valid id' do + it 'responds with a status code 200' do + get :show, environment_params + + expect(response).to be_ok + end + end + + context 'with invalid id' do + it 'responds with a status code 404' do + params = environment_params + params[:id] = 12345 + get :show, params + + expect(response).to have_http_status(404) + end + end + end + + describe 'GET edit' do + it 'responds with a status code 200' do + get :edit, environment_params + + expect(response).to be_ok + end + end + + describe 'PATCH #update' do + it 'responds with a 302' do + patch_params = environment_params.merge(environment: { external_url: 'https://git.gitlab.com' }) + patch :update, patch_params + + expect(response).to have_http_status(302) + end + end + + def environment_params + { + namespace_id: project.namespace, + project_id: project, + id: environment.id + } + end +end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 7cf09fa4a4a..ec820de3d09 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -6,37 +6,65 @@ describe Projects::IssuesController do let(:issue) { create(:issue, project: project) } describe "GET #index" do - before do - sign_in(user) - project.team << [user, :developer] - end + context 'external issue tracker' do + it 'redirects to the external issue tracker' do + external = double(issues_url: 'https://example.com/issues') + allow(project).to receive(:external_issue_tracker).and_return(external) + controller.instance_variable_set(:@project, project) - it "returns index" do - get :index, namespace_id: project.namespace.path, project_id: project.path + get :index, namespace_id: project.namespace.path, project_id: project - expect(response).to have_http_status(200) + expect(response).to redirect_to('https://example.com/issues') + end end - it "return 301 if request path doesn't match project path" do - get :index, namespace_id: project.namespace.path, project_id: project.path.upcase + context 'internal issue tracker' do + before do + sign_in(user) + project.team << [user, :developer] + end - expect(response).to redirect_to(namespace_project_issues_path(project.namespace, project)) - end + it "returns index" do + get :index, namespace_id: project.namespace.path, project_id: project.path + + expect(response).to have_http_status(200) + end - it "returns 404 when issues are disabled" do - project.issues_enabled = false - project.save + it "return 301 if request path doesn't match project path" do + get :index, namespace_id: project.namespace.path, project_id: project.path.upcase + + expect(response).to redirect_to(namespace_project_issues_path(project.namespace, project)) + end + + it "returns 404 when issues are disabled" do + project.issues_enabled = false + project.save + + get :index, namespace_id: project.namespace.path, project_id: project.path + expect(response).to have_http_status(404) + end - get :index, namespace_id: project.namespace.path, project_id: project.path - expect(response).to have_http_status(404) + it "returns 404 when external issue tracker is enabled" do + controller.instance_variable_set(:@project, project) + allow(project).to receive(:default_issues_tracker?).and_return(false) + + get :index, namespace_id: project.namespace.path, project_id: project.path + expect(response).to have_http_status(404) + end end + end + + describe 'GET #new' do + context 'external issue tracker' do + it 'redirects to the external issue tracker' do + external = double(new_issue_path: 'https://example.com/issues/new') + allow(project).to receive(:external_issue_tracker).and_return(external) + controller.instance_variable_set(:@project, project) - it "returns 404 when external issue tracker is enabled" do - controller.instance_variable_set(:@project, project) - allow(project).to receive(:default_issues_tracker?).and_return(false) + get :new, namespace_id: project.namespace.path, project_id: project - get :index, namespace_id: project.namespace.path, project_id: project.path - expect(response).to have_http_status(404) + expect(response).to redirect_to('https://example.com/issues/new') + end end end @@ -243,6 +271,37 @@ describe Projects::IssuesController do end end + describe 'POST #create' do + context 'Akismet is enabled' do + before do + allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) + end + + def post_spam_issue + sign_in(user) + spam_project = create(:empty_project, :public) + post :create, { + namespace_id: spam_project.namespace.to_param, + project_id: spam_project.to_param, + issue: { title: 'Spam Title', description: 'Spam lives here' } + } + end + + it 'rejects an issue recognized as spam' do + expect{ post_spam_issue }.not_to change(Issue, :count) + expect(response).to render_template(:new) + end + + it 'creates a spam log' do + post_spam_issue + spam_logs = SpamLog.all + expect(spam_logs.count).to eq(1) + expect(spam_logs[0].title).to eq('Spam Title') + end + end + end + describe "DELETE #destroy" do context "when the user is a developer" do before { sign_in(user) } diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 210085e3b1a..1f6bc84dfe8 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -392,9 +392,9 @@ describe Projects::MergeRequestsController do end it 'only renders the diffs for the path given' do - expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs, diff_refs, project| - expect(diffs.map(&:new_path)).to contain_exactly(existing_path) - meth.call(diffs, diff_refs, project) + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) end diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path) @@ -455,9 +455,9 @@ describe Projects::MergeRequestsController do end it 'only renders the diffs for the path given' do - expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs, diff_refs, project| - expect(diffs.map(&:new_path)).to contain_exactly(existing_path) - meth.call(diffs, diff_refs, project) + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) end diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' }) @@ -477,9 +477,9 @@ describe Projects::MergeRequestsController do end it 'only renders the diffs for the path given' do - expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs, diff_refs, project| - expect(diffs.map(&:new_path)).to contain_exactly(existing_path) - meth.call(diffs, diff_refs, project) + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) end diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' }) diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb new file mode 100644 index 00000000000..a6995145cc1 --- /dev/null +++ b/spec/controllers/projects/tags_controller_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Projects::TagsController do + let(:project) { create(:project, :public) } + let!(:release) { create(:release, project: project) } + let!(:invalid_release) { create(:release, project: project, tag: 'does-not-exist') } + + describe 'GET index' do + before { get :index, namespace_id: project.namespace.to_param, project_id: project.to_param } + + it 'returns the tags for the page' do + expect(assigns(:tags).map(&:name)).to eq(['v1.1.0', 'v1.0.0']) + end + + it 'returns releases matching those tags' do + expect(assigns(:releases)).to include(release) + expect(assigns(:releases)).not_to include(invalid_release) + end + end +end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 5e19e403c6b..1b32d560b16 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -90,5 +90,21 @@ FactoryGirl.define do build.save! end end + + trait :artifacts_expired do + after(:create) do |build, _| + build.artifacts_file = + fixture_file_upload(Rails.root.join('spec/fixtures/ci_build_artifacts.zip'), + 'application/zip') + + build.artifacts_metadata = + fixture_file_upload(Rails.root.join('spec/fixtures/ci_build_artifacts_metadata.gz'), + 'application/x-gzip') + + build.artifacts_expire_at = 1.minute.ago + + build.save! + end + end end end diff --git a/spec/factories/ci/trigger_requests.rb b/spec/factories/ci/trigger_requests.rb index 6d47d05f8ad..b8d8fab0e0b 100644 --- a/spec/factories/ci/trigger_requests.rb +++ b/spec/factories/ci/trigger_requests.rb @@ -5,7 +5,8 @@ FactoryGirl.define do variables do { - TRIGGER_KEY: 'TRIGGER_VALUE' + TRIGGER_KEY_1: 'TRIGGER_VALUE_1', + TRIGGER_KEY_2: 'TRIGGER_VALUE_2' } end end diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb index 07265c26ca3..846cccfc7fa 100644 --- a/spec/factories/environments.rb +++ b/spec/factories/environments.rb @@ -3,5 +3,6 @@ FactoryGirl.define do sequence(:name) { |n| "environment#{n}" } project factory: :empty_project + sequence(:external_url) { |n| "https://env#{n}.example.gitlab.com" } end end diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb index 28ed8078157..5575852c2d7 100644 --- a/spec/factories/protected_branches.rb +++ b/spec/factories/protected_branches.rb @@ -2,5 +2,28 @@ FactoryGirl.define do factory :protected_branch do name project + + after(:create) do |protected_branch| + protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) + protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) + end + + trait :developers_can_push do + after(:create) do |protected_branch| + protected_branch.push_access_level.update!(access_level: Gitlab::Access::DEVELOPER) + end + end + + trait :developers_can_merge do + after(:create) do |protected_branch| + protected_branch.merge_access_level.update!(access_level: Gitlab::Access::DEVELOPER) + end + end + + trait :no_one_can_push do + after(:create) do |protected_branch| + protected_branch.push_access_level.update!(access_level: Gitlab::Access::NO_ACCESS) + end + end end end diff --git a/spec/features/admin/admin_disables_git_access_protocol_spec.rb b/spec/features/admin/admin_disables_git_access_protocol_spec.rb index 5b1c0460274..66044b44495 100644 --- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb +++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb @@ -45,7 +45,6 @@ feature 'Admin disables Git access protocol', feature: true do expect(page).to have_content("git clone #{project.ssh_url_to_repo}") expect(page).to have_selector('#clone-dropdown') end - end def visit_project diff --git a/spec/features/builds_spec.rb b/spec/features/builds_spec.rb index cab3dc1d167..0cfeb2e57d8 100644 --- a/spec/features/builds_spec.rb +++ b/spec/features/builds_spec.rb @@ -199,9 +199,13 @@ describe "Builds" do click_link 'Retry' end - it { expect(page.status_code).to eq(200) } - it { expect(page).to have_content 'pending' } - it { expect(page).to have_content 'Cancel' } + it 'shows the right status and buttons' do + expect(page).to have_http_status(200) + expect(page).to have_content 'pending' + page.within('aside.right-sidebar') do + expect(page).to have_content 'Cancel' + end + end end context "Build from other project" do @@ -212,7 +216,25 @@ describe "Builds" do page.driver.post(retry_namespace_project_build_path(@project.namespace, @project, @build2)) end - it { expect(page.status_code).to eq(404) } + it { expect(page).to have_http_status(404) } + end + + context "Build that current user is not allowed to retry" do + before do + @build.run! + @build.cancel! + @project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + + logout_direct + login_with(create(:user)) + visit namespace_project_build_path(@project.namespace, @project, @build) + end + + it 'does not show the Retry button' do + page.within('aside.right-sidebar') do + expect(page).not_to have_content 'Retry' + end + end end end diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb index a7d9f2a0c72..fcd41b38413 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/environments_spec.rb @@ -140,7 +140,7 @@ feature 'Environments', feature: true do context 'for valid name' do before do fill_in('Name', with: 'production') - click_on 'Create environment' + click_on 'Save' end scenario 'does create a new pipeline' do @@ -151,7 +151,7 @@ feature 'Environments', feature: true do context 'for invalid name' do before do fill_in('Name', with: 'name with spaces') - click_on 'Create environment' + click_on 'Save' end scenario 'does show errors' do diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb new file mode 100644 index 00000000000..0d495cd04aa --- /dev/null +++ b/spec/features/issuables/default_sort_order_spec.rb @@ -0,0 +1,171 @@ +require 'spec_helper' + +describe 'Projects > Issuables > Default sort order', feature: true do + let(:project) { create(:empty_project, :public) } + + let(:first_created_issuable) { issuables.order_created_asc.first } + let(:last_created_issuable) { issuables.order_created_desc.first } + + let(:first_updated_issuable) { issuables.order_updated_asc.first } + let(:last_updated_issuable) { issuables.order_updated_desc.first } + + context 'for merge requests' do + include MergeRequestHelpers + + let!(:issuables) do + timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, + { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, + { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] + + timestamps.each_with_index do |ts, i| + create issuable_type, { title: "#{issuable_type}_#{i}", + source_branch: "#{issuable_type}_#{i}", + source_project: project }.merge(ts) + end + + MergeRequest.all + end + + context 'in the "merge requests" tab', js: true do + let(:issuable_type) { :merge_request } + + it 'is "last created"' do + visit_merge_requests project + + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + + context 'in the "merge requests / open" tab', js: true do + let(:issuable_type) { :merge_request } + + it 'is "last created"' do + visit_merge_requests_with_state(project, 'open') + + expect(selected_sort_order).to eq('last created') + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + + context 'in the "merge requests / merged" tab', js: true do + let(:issuable_type) { :merged_merge_request } + + it 'is "last updated"' do + visit_merge_requests_with_state(project, 'merged') + + expect(selected_sort_order).to eq('last updated') + expect(first_merge_request).to include(last_updated_issuable.title) + expect(last_merge_request).to include(first_updated_issuable.title) + end + end + + context 'in the "merge requests / closed" tab', js: true do + let(:issuable_type) { :closed_merge_request } + + it 'is "last updated"' do + visit_merge_requests_with_state(project, 'closed') + + expect(selected_sort_order).to eq('last updated') + expect(first_merge_request).to include(last_updated_issuable.title) + expect(last_merge_request).to include(first_updated_issuable.title) + end + end + + context 'in the "merge requests / all" tab', js: true do + let(:issuable_type) { :merge_request } + + it 'is "last created"' do + visit_merge_requests_with_state(project, 'all') + + expect(selected_sort_order).to eq('last created') + expect(first_merge_request).to include(last_created_issuable.title) + expect(last_merge_request).to include(first_created_issuable.title) + end + end + end + + context 'for issues' do + include IssueHelpers + + let!(:issuables) do + timestamps = [{ created_at: 3.minutes.ago, updated_at: 20.seconds.ago }, + { created_at: 2.minutes.ago, updated_at: 30.seconds.ago }, + { created_at: 4.minutes.ago, updated_at: 10.seconds.ago }] + + timestamps.each_with_index do |ts, i| + create issuable_type, { title: "#{issuable_type}_#{i}", + project: project }.merge(ts) + end + + Issue.all + end + + context 'in the "issues" tab', js: true do + let(:issuable_type) { :issue } + + it 'is "last created"' do + visit_issues project + + expect(selected_sort_order).to eq('last created') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'in the "issues / open" tab', js: true do + let(:issuable_type) { :issue } + + it 'is "last created"' do + visit_issues_with_state(project, 'open') + + expect(selected_sort_order).to eq('last created') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + + context 'in the "issues / closed" tab', js: true do + let(:issuable_type) { :closed_issue } + + it 'is "last updated"' do + visit_issues_with_state(project, 'closed') + + expect(selected_sort_order).to eq('last updated') + expect(first_issue).to include(last_updated_issuable.title) + expect(last_issue).to include(first_updated_issuable.title) + end + end + + context 'in the "issues / all" tab', js: true do + let(:issuable_type) { :issue } + + it 'is "last created"' do + visit_issues_with_state(project, 'all') + + expect(selected_sort_order).to eq('last created') + expect(first_issue).to include(last_created_issuable.title) + expect(last_issue).to include(first_created_issuable.title) + end + end + end + + def selected_sort_order + find('.pull-right .dropdown button').text.downcase + end + + def visit_merge_requests_with_state(project, state) + visit_merge_requests project + visit_issuables_with_state state + end + + def visit_issues_with_state(project, state) + visit_issues project + visit_issuables_with_state state + end + + def visit_issuables_with_state(state) + within('.issues-state-filters') { find("span", text: state.titleize).click } + end +end diff --git a/spec/features/issues/filter_by_labels_spec.rb b/spec/features/issues/filter_by_labels_spec.rb index 5ea02b8d39c..cb117d2476f 100644 --- a/spec/features/issues/filter_by_labels_spec.rb +++ b/spec/features/issues/filter_by_labels_spec.rb @@ -205,7 +205,7 @@ feature 'Issue filtering by Labels', feature: true do page.within '.labels-filter' do click_button 'Label' wait_for_ajax - fill_in 'label-name', with: 'bug' + find('.dropdown-input input').set 'bug' page.within '.dropdown-content' do expect(page).not_to have_content 'enhancement' diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index d51c9abea19..9c92b52898c 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe 'Issues', feature: true do + include IssueHelpers include SortingHelper let(:project) { create(:project) } @@ -186,15 +187,15 @@ describe 'Issues', feature: true do it 'sorts by newest' do visit namespace_project_issues_path(project.namespace, project, sort: sort_value_recently_created) - expect(first_issue).to include('baz') - expect(last_issue).to include('foo') + expect(first_issue).to include('foo') + expect(last_issue).to include('baz') end it 'sorts by oldest' do visit namespace_project_issues_path(project.namespace, project, sort: sort_value_oldest_created) - expect(first_issue).to include('foo') - expect(last_issue).to include('baz') + expect(first_issue).to include('baz') + expect(last_issue).to include('foo') end it 'sorts by most recently updated' do @@ -350,8 +351,8 @@ describe 'Issues', feature: true do sort: sort_value_oldest_created, assignee_id: user2.id) - expect(first_issue).to include('foo') - expect(last_issue).to include('bar') + expect(first_issue).to include('bar') + expect(last_issue).to include('foo') expect(page).not_to have_content 'baz' end end @@ -524,6 +525,35 @@ describe 'Issues', feature: true do end end + describe 'new issue by email' do + shared_examples 'show the email in the modal' do + before do + stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") + + visit namespace_project_issues_path(project.namespace, project) + click_button('Email a new issue') + end + + it 'click the button to show modal for the new email' do + page.within '#issue-email-modal' do + email = project.new_issue_address(@user) + + expect(page).to have_selector("input[value='#{email}']") + end + end + end + + context 'with existing issues' do + let!(:issue) { create(:issue, project: project, author: @user) } + + it_behaves_like 'show the email in the modal' + end + + context 'without existing issues' do + it_behaves_like 'show the email in the modal' + end + end + describe 'due date' do context 'update due on issue#show', js: true do let(:issue) { create(:issue, project: project, author: @user, assignee: @user) } @@ -561,14 +591,6 @@ describe 'Issues', feature: true do end end - def first_issue - page.all('ul.issues-list > li').first.text - end - - def last_issue - page.all('ul.issues-list > li').last.text - end - def drop_in_dropzone(file_path) # Generate a fake input selector page.execute_script <<-JS diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index 58753ff21f6..c4e8b1da531 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -128,7 +128,7 @@ feature 'Login', feature: true do end allow(Gitlab::OAuth::Provider).to receive_messages(providers: [:saml], config_for: saml_config) allow(Gitlab.config.omniauth).to receive_messages(messages) - allow_any_instance_of(Object).to receive(:user_omniauth_authorize_path).with('saml').and_return('/users/auth/saml') + expect_any_instance_of(Object).to receive(:omniauth_authorize_path).with(:user, "saml").and_return('/users/auth/saml') end it 'should show 2FA prompt after OAuth login' do diff --git a/spec/features/merge_requests/user_lists_merge_requests_spec.rb b/spec/features/merge_requests/user_lists_merge_requests_spec.rb index 1c130057c56..cabb8e455f9 100644 --- a/spec/features/merge_requests/user_lists_merge_requests_spec.rb +++ b/spec/features/merge_requests/user_lists_merge_requests_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe 'Projects > Merge requests > User lists merge requests', feature: true do + include MergeRequestHelpers include SortingHelper let(:project) { create(:project, :public) } @@ -23,10 +24,12 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true milestone: create(:milestone, due_date: '2013-12-12'), created_at: 2.minutes.ago, updated_at: 2.minutes.ago) + # lfs in itself is not a great choice for the title if one wants to match the whole body content later on + # just think about the scenario when faker generates 'Chester Runolfsson' as the user's name create(:merge_request, - title: 'lfs', + title: 'merge_lfs', source_project: project, - source_branch: 'lfs', + source_branch: 'merge_lfs', created_at: 3.minutes.ago, updated_at: 10.seconds.ago) end @@ -35,7 +38,7 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true visit_merge_requests(project, assignee_id: IssuableFinder::NONE) expect(current_path).to eq(namespace_project_merge_requests_path(project.namespace, project)) - expect(page).to have_content 'lfs' + expect(page).to have_content 'merge_lfs' expect(page).not_to have_content 'fix' expect(page).not_to have_content 'markdown' expect(count_merge_requests).to eq(1) @@ -44,7 +47,7 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true it 'filters on a specific assignee' do visit_merge_requests(project, assignee_id: user.id) - expect(page).not_to have_content 'lfs' + expect(page).not_to have_content 'merge_lfs' expect(page).to have_content 'fix' expect(page).to have_content 'markdown' expect(count_merge_requests).to eq(2) @@ -53,23 +56,23 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true it 'sorts by newest' do visit_merge_requests(project, sort: sort_value_recently_created) - expect(first_merge_request).to include('lfs') - expect(last_merge_request).to include('fix') + expect(first_merge_request).to include('fix') + expect(last_merge_request).to include('merge_lfs') expect(count_merge_requests).to eq(3) end it 'sorts by oldest' do visit_merge_requests(project, sort: sort_value_oldest_created) - expect(first_merge_request).to include('fix') - expect(last_merge_request).to include('lfs') + expect(first_merge_request).to include('merge_lfs') + expect(last_merge_request).to include('fix') expect(count_merge_requests).to eq(3) end it 'sorts by last updated' do visit_merge_requests(project, sort: sort_value_recently_updated) - expect(first_merge_request).to include('lfs') + expect(first_merge_request).to include('merge_lfs') expect(count_merge_requests).to eq(3) end @@ -143,18 +146,6 @@ describe 'Projects > Merge requests > User lists merge requests', feature: true end end - def visit_merge_requests(project, opts = {}) - visit namespace_project_merge_requests_path(project.namespace, project, opts) - end - - def first_merge_request - page.all('ul.mr-list > li').first.text - end - - def last_merge_request - page.all('ul.mr-list > li').last.text - end - def count_merge_requests page.all('ul.mr-list > li').count end diff --git a/spec/features/pipelines_spec.rb b/spec/features/pipelines_spec.rb index 7f861db1969..377a9aba60d 100644 --- a/spec/features/pipelines_spec.rb +++ b/spec/features/pipelines_spec.rb @@ -116,9 +116,19 @@ describe "Pipelines" do it { expect(page).to have_link(with_artifacts.name) } end + context 'with artifacts expired' do + let!(:with_artifacts_expired) { create(:ci_build, :artifacts_expired, :success, pipeline: pipeline, name: 'rspec', stage: 'test') } + + before { visit namespace_project_pipelines_path(project.namespace, project) } + + it { expect(page).not_to have_selector('.build-artifacts') } + end + context 'without artifacts' do let!(:without_artifacts) { create(:ci_build, :success, pipeline: pipeline, name: 'rspec', stage: 'test') } + before { visit namespace_project_pipelines_path(project.namespace, project) } + it { expect(page).not_to have_selector('.build-artifacts') } end end diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb new file mode 100644 index 00000000000..4cbdd89d46f --- /dev/null +++ b/spec/features/profiles/password_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe 'Profile > Password', feature: true do + let(:user) { create(:user, password_automatically_set: true) } + + before do + login_as(user) + visit edit_profile_password_path + end + + def fill_passwords(password, confirmation) + fill_in 'New password', with: password + fill_in 'Password confirmation', with: confirmation + + click_button 'Save password' + end + + context 'User with password automatically set' do + describe 'User puts different passwords in the field and in the confirmation' do + it 'shows an error message' do + fill_passwords('mypassword', 'mypassword2') + + page.within('.alert-danger') do + expect(page).to have_content("Password confirmation doesn't match Password") + end + end + + it 'does not contains the current password field after an error' do + fill_passwords('mypassword', 'mypassword2') + + expect(page).to have_no_field('user[current_password]') + end + end + + describe 'User puts the same passwords in the field and in the confirmation' do + it 'shows a success message' do + fill_passwords('mypassword', 'mypassword') + + page.within('.flash-notice') do + expect(page).to have_content('Password was successfully updated. Please login with it') + end + end + end + end +end diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb new file mode 100644 index 00000000000..79abba21854 --- /dev/null +++ b/spec/features/projects/branches_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe 'Branches', feature: true do + let(:project) { create(:project) } + let(:repository) { project.repository } + + before do + login_as :user + project.team << [@user, :developer] + end + + describe 'Initial branches page' do + it 'shows all the branches' do + visit namespace_project_branches_path(project.namespace, project) + + repository.branches { |branch| expect(page).to have_content("#{branch.name}") } + expect(page).to have_content("Protected branches can be managed in project settings") + end + end + + describe 'Find branches' do + it 'shows filtered branches', js: true do + visit namespace_project_branches_path(project.namespace, project, project.id) + + fill_in 'branch-search', with: 'fix' + find('#branch-search').native.send_keys(:enter) + + expect(page).to have_content('fix') + expect(find('.all-branches')).to have_selector('li', count: 1) + end + end +end diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 2d1e3bbebe5..7835e1678ad 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -8,6 +8,7 @@ feature 'project import', feature: true, js: true do let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') } let(:export_path) { "#{Dir::tmpdir}/import_file_spec" } let(:project) { Project.last } + let(:project_hook) { Gitlab::Git::Hook.new('post-receive', project.repository.path) } background do allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) @@ -37,7 +38,7 @@ feature 'project import', feature: true, js: true do expect(project).not_to be_nil expect(project.issues).not_to be_empty expect(project.merge_requests).not_to be_empty - expect(project.repo_exists?).to be true + expect(project_hook).to exist expect(wiki_exists?).to be true expect(project.import_status).to eq('finished') end diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb new file mode 100644 index 00000000000..3de25d7af7d --- /dev/null +++ b/spec/features/projects/project_settings_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe 'Edit Project Settings', feature: true do + let(:user) { create(:user) } + let(:project) { create(:empty_project, path: 'gitlab', name: 'sample') } + + before do + login_as(user) + project.team << [user, :master] + end + + describe 'Project settings', 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, '_', '.', dash and space. It must start with letter, digit or '_'." + expect(page).to have_button 'Save changes' + 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' + + click_button 'Rename project' + + 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, '_', '.', dash and space. It must start with letter, digit 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 +end diff --git a/spec/features/projects/ref_switcher_spec.rb b/spec/features/projects/ref_switcher_spec.rb new file mode 100644 index 00000000000..b3ba40b35af --- /dev/null +++ b/spec/features/projects/ref_switcher_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +feature 'Ref switcher', feature: true, js: true do + include WaitForAjax + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + + before do + project.team << [user, :master] + login_as(user) + visit namespace_project_tree_path(project.namespace, project, 'master') + end + + it 'allow user to change ref by enter key' do + click_button 'master' + wait_for_ajax + + page.within '.project-refs-form' do + input = find('input[type="search"]') + input.set 'expand' + + input.native.send_keys :down + input.native.send_keys :down + input.native.send_keys :enter + + expect(page).to have_content 'expand-collapse-files' + end + end +end diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb new file mode 100644 index 00000000000..a1c386ddc18 --- /dev/null +++ b/spec/features/projects/wiki/markdown_preview_spec.rb @@ -0,0 +1,140 @@ +require 'spec_helper' + +feature 'Projects > Wiki > User previews markdown changes', feature: true, js: true do + let(:user) { create(:user) } + let(:project) { create(:project, namespace: user.namespace) } + let(:wiki_content) do + <<-HEREDOC +[regular link](regular) +[relative link 1](../relative) +[relative link 2](./relative) +[relative link 3](./e/f/relative) + HEREDOC + end + + background do + project.team << [user, :master] + login_as(user) + + visit namespace_project_path(project.namespace, project) + click_link 'Wiki' + WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute + end + + context "while creating a new wiki page" do + context "when there are no spaces or hyphens in the page name" do + it "rewrites relative links as expected" do + click_link 'New Page' + fill_in :new_wiki_path, with: 'a/b/c/d' + click_button 'Create Page' + + fill_in :wiki_content, with: wiki_content + click_on "Preview" + + expect(page).to have_content("regular link") + + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/relative\">relative link 1</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/c/relative\">relative link 2</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/c/e/f/relative\">relative link 3</a>") + end + end + + context "when there are spaces in the page name" do + it "rewrites relative links as expected" do + click_link 'New Page' + fill_in :new_wiki_path, with: 'a page/b page/c page/d page' + click_button 'Create Page' + + fill_in :wiki_content, with: wiki_content + click_on "Preview" + + expect(page).to have_content("regular link") + + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/relative\">relative link 1</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>") + end + end + + context "when there are hyphens in the page name" do + it "rewrites relative links as expected" do + click_link 'New Page' + fill_in :new_wiki_path, with: 'a-page/b-page/c-page/d-page' + click_button 'Create Page' + + fill_in :wiki_content, with: wiki_content + click_on "Preview" + + expect(page).to have_content("regular link") + + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/relative\">relative link 1</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>") + end + end + end + + context "while editing a wiki page" do + def create_wiki_page(path) + click_link 'New Page' + fill_in :new_wiki_path, with: path + click_button 'Create Page' + fill_in :wiki_content, with: 'content' + click_on "Create page" + end + + context "when there are no spaces or hyphens in the page name" do + it "rewrites relative links as expected" do + create_wiki_page 'a/b/c/d' + click_link 'Edit' + + fill_in :wiki_content, with: wiki_content + click_on "Preview" + + expect(page).to have_content("regular link") + + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/relative\">relative link 1</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/c/relative\">relative link 2</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a/b/c/e/f/relative\">relative link 3</a>") + end + end + + context "when there are spaces in the page name" do + it "rewrites relative links as expected" do + create_wiki_page 'a page/b page/c page/d page' + click_link 'Edit' + + fill_in :wiki_content, with: wiki_content + click_on "Preview" + + expect(page).to have_content("regular link") + + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/relative\">relative link 1</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>") + end + end + + context "when there are hyphens in the page name" do + it "rewrites relative links as expected" do + create_wiki_page 'a-page/b-page/c-page/d-page' + click_link 'Edit' + + fill_in :wiki_content, with: wiki_content + click_on "Preview" + + expect(page).to have_content("regular link") + + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/regular\">regular link</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/relative\">relative link 1</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/relative\">relative link 2</a>") + expect(page.html).to include("<a href=\"/#{project.path_with_namespace}/wikis/a-page/b-page/c-page/e/f/relative\">relative link 3</a>") + end + end + end +end diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb index 7e6eef65873..7afd83b7250 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -30,18 +30,48 @@ feature 'Projects > Wiki > User creates wiki page', feature: true do WikiPages::CreateService.new(project, user, title: 'home', content: 'Home page').execute end - scenario 'via the "new wiki page" page', js: true do - click_link 'New Page' + context 'via the "new wiki page" page' do + scenario 'when the wiki page has a single word name', js: true do + click_link 'New Page' - fill_in :new_wiki_path, with: 'foo' - click_button 'Create Page' + fill_in :new_wiki_path, with: 'foo' + click_button 'Create Page' - fill_in :wiki_content, with: 'My awesome wiki!' - click_button 'Create page' + fill_in :wiki_content, with: 'My awesome wiki!' + click_button 'Create page' - expect(page).to have_content('Foo') - expect(page).to have_content("last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + expect(page).to have_content('Foo') + expect(page).to have_content("last edited by #{user.name}") + expect(page).to have_content('My awesome wiki!') + end + + scenario 'when the wiki page has spaces in the name', js: true do + click_link 'New Page' + + fill_in :new_wiki_path, with: 'Spaces in the name' + click_button 'Create Page' + + fill_in :wiki_content, with: 'My awesome wiki!' + click_button 'Create page' + + expect(page).to have_content('Spaces in the name') + expect(page).to have_content("last edited by #{user.name}") + expect(page).to have_content('My awesome wiki!') + end + + scenario 'when the wiki page has hyphens in the name', js: true do + click_link 'New Page' + + fill_in :new_wiki_path, with: 'hyphens-in-the-name' + click_button 'Create Page' + + fill_in :wiki_content, with: 'My awesome wiki!' + click_button 'Create page' + + expect(page).to have_content('Hyphens in the name') + expect(page).to have_content("last edited by #{user.name}") + expect(page).to have_content('My awesome wiki!') + end end end end diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index d94dee0c797..3499460c84d 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Projected Branches', feature: true, js: true do + include WaitForAjax + let(:user) { create(:user, :admin) } let(:project) { create(:project) } @@ -9,7 +11,7 @@ feature 'Projected Branches', feature: true, js: true do def set_protected_branch_name(branch_name) find(".js-protected-branch-select").click find(".dropdown-input-field").set(branch_name) - click_on "Create Protected Branch: #{branch_name}" + click_on("Create wildcard #{branch_name}") end describe "explicit protected branches" do @@ -81,4 +83,68 @@ feature 'Projected Branches', feature: true, js: true do end end end + + describe "access control" do + ProtectedBranch::PushAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| + it "allows creating protected branches that #{access_type_name} can push to" do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + within('.new_protected_branch') do + find(".js-allowed-to-push").click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end + click_on "Protect" + + expect(ProtectedBranch.count).to eq(1) + expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id) + end + + it "allows updating protected branches so that #{access_type_name} can push to them" do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + click_on "Protect" + + expect(ProtectedBranch.count).to eq(1) + + within(".protected-branches-list") do + find(".js-allowed-to-push").click + within('.js-allowed-to-push-container') { click_on access_type_name } + end + + wait_for_ajax + expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id) + end + end + + ProtectedBranch::MergeAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| + it "allows creating protected branches that #{access_type_name} can merge to" do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + within('.new_protected_branch') do + find(".js-allowed-to-merge").click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end + click_on "Protect" + + expect(ProtectedBranch.count).to eq(1) + expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id) + end + + it "allows updating protected branches so that #{access_type_name} can merge to them" do + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + click_on "Protect" + + expect(ProtectedBranch.count).to eq(1) + + within(".protected-branches-list") do + find(".js-allowed-to-merge").click + within('.js-allowed-to-merge-container') { click_on access_type_name } + end + + wait_for_ajax + expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id) + end + end + end end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index d0a301038c4..09f70cd3b00 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -28,6 +28,26 @@ describe "Search", feature: true do end context 'search for comments' do + context 'when comment belongs to a invalid commit' do + let(:note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'Bug here') } + + before { note.update_attributes(commit_id: 12345678) } + + it 'finds comment' do + visit namespace_project_path(project.namespace, project) + + page.within '.search' do + fill_in 'search', with: note.note + click_button 'Go' + end + + click_link 'Comments' + + expect(page).to have_text("Commit deleted") + expect(page).to have_text("12345678") + end + end + it 'finds a snippet' do snippet = create(:project_snippet, :private, project: project, author: user, title: 'Some title') note = create(:note, diff --git a/spec/finders/branches_finder_spec.rb b/spec/finders/branches_finder_spec.rb new file mode 100644 index 00000000000..6fce11de30f --- /dev/null +++ b/spec/finders/branches_finder_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +describe BranchesFinder do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:repository) { project.repository } + + describe '#execute' do + context 'sort only' do + it 'sorts by name' do + branches_finder = described_class.new(repository, {}) + + result = branches_finder.execute + + expect(result.first.name).to eq("'test'") + end + + it 'sorts by recently_updated' do + branches_finder = described_class.new(repository, { sort: 'updated_desc' }) + + result = branches_finder.execute + + recently_updated_branch = repository.branches.max do |a, b| + repository.commit(a.target).committed_date <=> repository.commit(b.target).committed_date + end + + expect(result.first.name).to eq(recently_updated_branch.name) + end + + it 'sorts by last_updated' do + branches_finder = described_class.new(repository, { sort: 'updated_asc' }) + + result = branches_finder.execute + + expect(result.first.name).to eq('feature') + end + end + + context 'filter only' do + it 'filters branches by name' do + branches_finder = described_class.new(repository, { search: 'fix' }) + + result = branches_finder.execute + + expect(result.first.name).to eq('fix') + expect(result.count).to eq(1) + end + + it 'does not find any branch with that name' do + branches_finder = described_class.new(repository, { search: 'random' }) + + result = branches_finder.execute + + expect(result.count).to eq(0) + end + end + + context 'filter and sort' do + it 'filters branches by name and sorts by recently_updated' do + params = { sort: 'updated_desc', search: 'feature' } + branches_finder = described_class.new(repository, params) + + result = branches_finder.execute + + expect(result.first.name).to eq('feature_conflict') + expect(result.count).to eq(2) + end + + it 'filters branches by name and sorts by last_updated' do + params = { sort: 'updated_asc', search: 'feature' } + branches_finder = described_class.new(repository, params) + + result = branches_finder.execute + + expect(result.first.name).to eq('feature') + expect(result.count).to eq(2) + end + end + end +end diff --git a/spec/fixtures/emails/valid_new_issue.eml b/spec/fixtures/emails/valid_new_issue.eml new file mode 100644 index 00000000000..3cf53a656a5 --- /dev/null +++ b/spec/fixtures/emails/valid_new_issue.eml @@ -0,0 +1,23 @@ +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> +To: incoming+gitlabhq/gitlabhq+auth_token@appmail.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/fixtures/emails/valid_new_issue_empty.eml b/spec/fixtures/emails/valid_new_issue_empty.eml new file mode 100644 index 00000000000..fc1d52a3f42 --- /dev/null +++ b/spec/fixtures/emails/valid_new_issue_empty.eml @@ -0,0 +1,18 @@ +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> +To: incoming+gitlabhq/gitlabhq+auth_token@appmail.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 diff --git a/spec/fixtures/emails/wrong_authentication_token.eml b/spec/fixtures/emails/wrong_authentication_token.eml new file mode 100644 index 00000000000..0994c2f7775 --- /dev/null +++ b/spec/fixtures/emails/wrong_authentication_token.eml @@ -0,0 +1,18 @@ +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> +To: incoming+gitlabhq/gitlabhq+bad_token@appmail.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 diff --git a/spec/fixtures/emails/wrong_reply_key.eml b/spec/fixtures/emails/wrong_mail_key.eml index 491e078fb5b..491e078fb5b 100644 --- a/spec/fixtures/emails/wrong_reply_key.eml +++ b/spec/fixtures/emails/wrong_mail_key.eml diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index bb28866f010..3e15a137e33 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -218,12 +218,12 @@ describe ApplicationHelper do end it 'includes a default js-timeago class' do - expect(element.attr('class')).to eq 'time_ago js-timeago js-timeago-pending' + expect(element.attr('class')).to eq 'js-timeago js-timeago-pending' end it 'accepts a custom html_class' do expect(element(html_class: 'custom_class').attr('class')). - to eq 'custom_class js-timeago js-timeago-pending' + to eq 'js-timeago custom_class js-timeago-pending' end it 'accepts a custom tooltip placement' do @@ -244,6 +244,19 @@ describe ApplicationHelper do it 'converts to Time' do expect { helper.time_ago_with_tooltip(Date.today) }.not_to raise_error end + + it 'add class for the short format and includes inline script' do + timeago_element = element(short_format: 'short') + expect(timeago_element.attr('class')).to eq 'js-short-timeago js-timeago-pending' + script_element = timeago_element.next_element + expect(script_element.name).to eq 'script' + end + + it 'add class for the short format and does not include inline script' do + timeago_element = element(short_format: 'short', skip_js: true) + expect(timeago_element.attr('class')).to eq 'js-short-timeago' + expect(timeago_element.next_element).to eq nil + end end describe 'render_markup' do diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index bd0108f9938..b2d6d59b1ee 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe BlobHelper do + include TreeHelper + let(:blob_name) { 'test.lisp' } let(:no_context_content) { ":type \"assem\"))" } let(:blob_content) { "(make-pathname :defaults name\n#{no_context_content}" } @@ -65,4 +67,20 @@ describe BlobHelper do expect(sanitize_svg(blob).data).to eq(expected) end end + + describe "#edit_blob_link" do + let(:project) { create(:project) } + + before do + allow(self).to receive(:current_user).and_return(double) + end + + it 'verifies blob is text' do + expect(self).not_to receive(:blob_text_viewable?) + + button = edit_blob_link(project, 'refs/heads/master', 'README.md') + + expect(button).to start_with('<button') + end + end end diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb index c2fd2c8a533..b6554de1c64 100644 --- a/spec/helpers/diff_helper_spec.rb +++ b/spec/helpers/diff_helper_spec.rb @@ -6,7 +6,7 @@ describe DiffHelper do let(:project) { create(:project) } let(:repository) { project.repository } let(:commit) { project.commit(sample_commit.id) } - let(:diffs) { commit.diffs } + let(:diffs) { commit.raw_diffs } let(:diff) { diffs.first } let(:diff_refs) { [commit.parent, commit] } let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } @@ -15,33 +15,23 @@ describe DiffHelper do it 'returns a valid value when cookie is set' do helper.request.cookies[:diff_view] = 'parallel' - expect(helper.diff_view).to eq 'parallel' + expect(helper.diff_view).to eq :parallel end it 'returns a default value when cookie is invalid' do helper.request.cookies[:diff_view] = 'invalid' - expect(helper.diff_view).to eq 'inline' + expect(helper.diff_view).to eq :inline end it 'returns a default value when cookie is nil' do expect(helper.request.cookies).to be_empty - expect(helper.diff_view).to eq 'inline' + expect(helper.diff_view).to eq :inline end end - - describe 'diff_options' do - it 'should return hard limit for a diff if force diff is true' do - allow(controller).to receive(:params) { { force_show_diff: true } } - expect(diff_options).to include(Commit.max_diff_options) - end - - it 'should return hard limit for a diff if expand_all_diffs is true' do - allow(controller).to receive(:params) { { expand_all_diffs: true } } - expect(diff_options).to include(Commit.max_diff_options) - end + describe 'diff_options' do it 'should return no collapse false' do expect(diff_options).to include(no_collapse: false) end @@ -55,25 +45,17 @@ describe DiffHelper do allow(controller).to receive(:action_name) { 'diff_for_path' } expect(diff_options).to include(no_collapse: true) end - end - - describe 'unfold_bottom_class' do - it 'should return empty string when bottom line shouldnt be unfolded' do - expect(unfold_bottom_class(false)).to eq('') - end - - it 'should return js class when bottom lines should be unfolded' do - expect(unfold_bottom_class(true)).to include('js-unfold-bottom') - end - end - describe 'unfold_class' do - it 'returns empty on false' do - expect(unfold_class(false)).to eq('') + it 'should return paths if action name diff_for_path and param old path' do + allow(controller).to receive(:params) { { old_path: 'lib/wadus.rb' } } + allow(controller).to receive(:action_name) { 'diff_for_path' } + expect(diff_options[:paths]).to include('lib/wadus.rb') end - it 'returns a class on true' do - expect(unfold_class(true)).to eq('unfold js-unfold') + it 'should return paths if action name diff_for_path and param new path' do + allow(controller).to receive(:params) { { new_path: 'lib/wadus.rb' } } + allow(controller).to receive(:action_name) { 'diff_for_path' } + expect(diff_options[:paths]).to include('lib/wadus.rb') end end @@ -103,4 +85,56 @@ describe DiffHelper do expect(marked_new_line).to be_html_safe end end + + describe "#diff_match_line" do + let(:old_pos) { 40 } + let(:new_pos) { 50 } + let(:text) { 'some_text' } + + it "should generate foldable top match line for inline view with empty text by default" do + output = diff_match_line old_pos, new_pos + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.old_line[data-linenumber='#{old_pos}']", text: '...' + expect(output).to have_css "td:nth-child(2):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.new_line[data-linenumber='#{new_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(3):not(.parallel).line_content.match', text: '' + end + + it "should allow to define text and bottom option" do + output = diff_match_line old_pos, new_pos, text: text, bottom: true + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1).diff-line-num.unfold.js-unfold.js-unfold-bottom.old_line[data-linenumber='#{old_pos}']", text: '...' + expect(output).to have_css "td:nth-child(2).diff-line-num.unfold.js-unfold.js-unfold-bottom.new_line[data-linenumber='#{new_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(3):not(.parallel).line_content.match', text: text + end + + it "should generate match line for parallel view" do + output = diff_match_line old_pos, new_pos, text: text, view: :parallel + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.old_line[data-linenumber='#{old_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(2).line_content.match.parallel', text: text + expect(output).to have_css "td:nth-child(3):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.new_line[data-linenumber='#{new_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(4).line_content.match.parallel', text: text + end + + it "should allow to generate only left match line for parallel view" do + output = diff_match_line old_pos, nil, text: text, view: :parallel + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.old_line[data-linenumber='#{old_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(2).line_content.match.parallel', text: text + expect(output).not_to have_css 'td:nth-child(3)' + end + + it "should allow to generate only right match line for parallel view" do + output = diff_match_line nil, new_pos, text: text, view: :parallel + + expect(output).to be_html_safe + expect(output).to have_css "td:nth-child(1):not(.js-unfold-bottom).diff-line-num.unfold.js-unfold.new_line[data-linenumber='#{new_pos}']", text: '...' + expect(output).to have_css 'td:nth-child(2).line_content.match.parallel', text: text + expect(output).not_to have_css 'td:nth-child(3)' + end + end end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 831ae7fb69c..9ee46dd2508 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -5,52 +5,6 @@ describe IssuesHelper do let(:issue) { create :issue, project: project } let(:ext_project) { create :redmine_project } - describe "url_for_project_issues" do - let(:project_url) { ext_project.external_issue_tracker.project_url } - let(:ext_expected) { project_url.gsub(':project_id', ext_project.id.to_s) } - let(:int_expected) { polymorphic_path([@project.namespace, project]) } - - it "should return internal path if used internal tracker" do - @project = project - expect(url_for_project_issues).to match(int_expected) - end - - it "should return path to external tracker" do - @project = ext_project - - expect(url_for_project_issues).to match(ext_expected) - end - - it "should return empty string if project nil" do - @project = nil - - expect(url_for_project_issues).to eq "" - end - - it 'returns an empty string if project_url is invalid' do - expect(project).to receive_message_chain('issues_tracker.project_url') { 'javascript:alert("foo");' } - - expect(url_for_project_issues(project)).to eq '' - end - - it 'returns an empty string if project_path is invalid' do - expect(project).to receive_message_chain('issues_tracker.project_path') { 'javascript:alert("foo");' } - - expect(url_for_project_issues(project, only_path: true)).to eq '' - end - - describe "when external tracker was enabled and then config removed" do - before do - @project = ext_project - allow(Gitlab.config).to receive(:issues_tracker).and_return(nil) - end - - it "should return path to external tracker" do - expect(url_for_project_issues).to match(ext_expected) - end - end - end - describe "url_for_issue" do let(:issues_url) { ext_project.external_issue_tracker.issues_url} let(:ext_expected) { issues_url.gsub(':id', issue.iid.to_s).gsub(':project_id', ext_project.id.to_s) } @@ -97,52 +51,6 @@ describe IssuesHelper do end end - describe 'url_for_new_issue' do - let(:issues_url) { ext_project.external_issue_tracker.new_issue_url } - let(:ext_expected) { issues_url.gsub(':project_id', ext_project.id.to_s) } - let(:int_expected) { new_namespace_project_issue_path(project.namespace, project) } - - it "should return internal path if used internal tracker" do - @project = project - expect(url_for_new_issue).to match(int_expected) - end - - it "should return path to external tracker" do - @project = ext_project - - expect(url_for_new_issue).to match(ext_expected) - end - - it "should return empty string if project nil" do - @project = nil - - expect(url_for_new_issue).to eq "" - end - - it 'returns an empty string if issue_url is invalid' do - expect(project).to receive_message_chain('issues_tracker.new_issue_url') { 'javascript:alert("foo");' } - - expect(url_for_new_issue(project)).to eq '' - end - - it 'returns an empty string if issue_path is invalid' do - expect(project).to receive_message_chain('issues_tracker.new_issue_path') { 'javascript:alert("foo");' } - - expect(url_for_new_issue(project, only_path: true)).to eq '' - end - - describe "when external tracker was enabled and then config removed" do - before do - @project = ext_project - allow(Gitlab.config).to receive(:issues_tracker).and_return(nil) - end - - it "should return internal path" do - expect(url_for_new_issue).to match(ext_expected) - end - end - end - describe "merge_requests_sentence" do subject { merge_requests_sentence(merge_requests)} let(:merge_requests) do diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index 08a93503258..af371248ae9 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -1,37 +1,30 @@ require "spec_helper" describe NotesHelper do - describe "#notes_max_access_for_users" do - let(:owner) { create(:owner) } - let(:group) { create(:group) } - let(:project) { create(:empty_project, namespace: group) } - let(:master) { create(:user) } - let(:reporter) { create(:user) } - let(:guest) { create(:user) } - - let(:owner_note) { create(:note, author: owner, project: project) } - let(:master_note) { create(:note, author: master, project: project) } - let(:reporter_note) { create(:note, author: reporter, project: project) } - let!(:notes) { [owner_note, master_note, reporter_note] } - - before do - group.add_owner(owner) - project.team << [master, :master] - project.team << [reporter, :reporter] - project.team << [guest, :guest] - end + let(:owner) { create(:owner) } + let(:group) { create(:group) } + let(:project) { create(:empty_project, namespace: group) } + let(:master) { create(:user) } + let(:reporter) { create(:user) } + let(:guest) { create(:user) } - it 'return human access levels' do - original_method = project.team.method(:human_max_access) - expect_any_instance_of(ProjectTeam).to receive(:human_max_access).exactly(3).times do |*args| - original_method.call(args[1]) - end + let(:owner_note) { create(:note, author: owner, project: project) } + let(:master_note) { create(:note, author: master, project: project) } + let(:reporter_note) { create(:note, author: reporter, project: project) } + let!(:notes) { [owner_note, master_note, reporter_note] } + before do + group.add_owner(owner) + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [guest, :guest] + end + + describe "#notes_max_access_for_users" do + it 'return human access levels' do expect(helper.note_max_access_for_user(owner_note)).to eq('Owner') expect(helper.note_max_access_for_user(master_note)).to eq('Master') expect(helper.note_max_access_for_user(reporter_note)).to eq('Reporter') - # Call it again to ensure value is cached - expect(helper.note_max_access_for_user(owner_note)).to eq('Owner') end it 'handles access in different projects' do @@ -43,4 +36,16 @@ describe NotesHelper do expect(helper.note_max_access_for_user(other_note)).to eq('Reporter') end end + + describe '#preload_max_access_for_authors' do + it 'loads multiple users' do + expected_access = { + owner.id => Gitlab::Access::OWNER, + master.id => Gitlab::Access::MASTER, + reporter.id => Gitlab::Access::REPORTER + } + + expect(helper.preload_max_access_for_authors(notes, project)).to eq(expected_access) + end + end end diff --git a/spec/initializers/trusted_proxies_spec.rb b/spec/initializers/trusted_proxies_spec.rb index 14c8df954a6..290e47763eb 100644 --- a/spec/initializers/trusted_proxies_spec.rb +++ b/spec/initializers/trusted_proxies_spec.rb @@ -17,6 +17,12 @@ describe 'trusted_proxies', lib: true do expect(request.remote_ip).to eq('10.1.5.89') expect(request.ip).to eq('10.1.5.89') end + + it 'filters out bad values' do + request = stub_request('HTTP_X_FORWARDED_FOR' => '(null), 10.1.5.89') + expect(request.remote_ip).to eq('10.1.5.89') + expect(request.ip).to eq('10.1.5.89') + end end context 'with private IP ranges added' do @@ -41,6 +47,12 @@ describe 'trusted_proxies', lib: true do expect(request.remote_ip).to eq('1.1.1.1') expect(request.ip).to eq('1.1.1.1') end + + it 'handles invalid ip addresses' do + request = stub_request('HTTP_X_FORWARDED_FOR' => '(null), 1.1.1.1:12345, 1.1.1.1') + expect(request.remote_ip).to eq('1.1.1.1') + expect(request.ip).to eq('1.1.1.1') + end end def stub_request(headers = {}) diff --git a/spec/javascripts/application_spec.js b/spec/javascripts/application_spec.js new file mode 100644 index 00000000000..b48026c3b77 --- /dev/null +++ b/spec/javascripts/application_spec.js @@ -0,0 +1,32 @@ + +/*= require lib/utils/common_utils */ + +(function() { + describe('Application', function() { + return describe('disable buttons', function() { + fixture.preload('application.html'); + beforeEach(function() { + return fixture.load('application.html'); + }); + it('should prevent default action for disabled buttons', function() { + var $button, isClicked; + gl.utils.preventDisabledButtons(); + isClicked = false; + $button = $('#test-button'); + $button.click(function() { + return isClicked = true; + }); + $button.trigger('click'); + return expect(isClicked).toBe(false); + }); + return it('should be on the same page if a disabled link clicked', function() { + var locationBeforeLinkClick; + locationBeforeLinkClick = window.location.href; + gl.utils.preventDisabledButtons(); + $('#test-link').click(); + return expect(window.location.href).toBe(locationBeforeLinkClick); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/application_spec.js.coffee b/spec/javascripts/application_spec.js.coffee deleted file mode 100644 index 4b6a2bb5440..00000000000 --- a/spec/javascripts/application_spec.js.coffee +++ /dev/null @@ -1,30 +0,0 @@ -#= require lib/utils/common_utils - -describe 'Application', -> - describe 'disable buttons', -> - fixture.preload('application.html') - - beforeEach -> - fixture.load('application.html') - - it 'should prevent default action for disabled buttons', -> - - gl.utils.preventDisabledButtons() - - isClicked = false - $button = $ '#test-button' - - $button.click -> isClicked = true - $button.trigger 'click' - - expect(isClicked).toBe false - - - it 'should be on the same page if a disabled link clicked', -> - - locationBeforeLinkClick = window.location.href - gl.utils.preventDisabledButtons() - - $('#test-link').click() - - expect(window.location.href).toBe locationBeforeLinkClick diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js new file mode 100644 index 00000000000..3ddc163033e --- /dev/null +++ b/spec/javascripts/awards_handler_spec.js @@ -0,0 +1,187 @@ + +/*= require awards_handler */ + + +/*= require jquery */ + + +/*= require jquery.cookie */ + + +/*= require ./fixtures/emoji_menu */ + +(function() { + var awardsHandler, lazyAssert; + + awardsHandler = null; + + window.gl || (window.gl = {}); + + window.gon || (window.gon = {}); + + gl.emojiAliases = function() { + return { + '+1': 'thumbsup', + '-1': 'thumbsdown' + }; + }; + + gon.award_menu_url = '/emojis'; + + lazyAssert = function(done, assertFn) { + return setTimeout(function() { + assertFn(); + return done(); + }, 333); + }; + + describe('AwardsHandler', function() { + fixture.preload('awards_handler.html'); + beforeEach(function() { + fixture.load('awards_handler.html'); + awardsHandler = new AwardsHandler; + spyOn(awardsHandler, 'postEmoji').and.callFake((function(_this) { + return function(url, emoji, cb) { + return cb(); + }; + })(this)); + return spyOn(jQuery, 'get').and.callFake(function(req, cb) { + return cb(window.emojiMenu); + }); + }); + describe('::showEmojiMenu', function() { + it('should show emoji menu when Add emoji button clicked', function(done) { + $('.js-add-award').eq(0).click(); + return lazyAssert(done, function() { + var $emojiMenu; + $emojiMenu = $('.emoji-menu'); + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(true); + expect($emojiMenu.find('#emoji_search').length).toBe(1); + return expect($('.js-awards-block.current').length).toBe(1); + }); + }); + it('should also show emoji menu for the smiley icon in notes', function(done) { + $('.note-action-button').click(); + return lazyAssert(done, function() { + var $emojiMenu; + $emojiMenu = $('.emoji-menu'); + return expect($emojiMenu.length).toBe(1); + }); + }); + return it('should remove emoji menu when body is clicked', function(done) { + $('.js-add-award').eq(0).click(); + return lazyAssert(done, function() { + var $emojiMenu; + $emojiMenu = $('.emoji-menu'); + $('body').click(); + expect($emojiMenu.length).toBe(1); + expect($emojiMenu.hasClass('is-visible')).toBe(false); + return expect($('.js-awards-block.current').length).toBe(0); + }); + }); + }); + describe('::addAwardToEmojiBar', function() { + it('should add emoji to votes block', function() { + var $emojiButton, $votesBlock; + $votesBlock = $('.js-awards-block').eq(0); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + $emojiButton = $votesBlock.find('[data-emoji=heart]'); + expect($emojiButton.length).toBe(1); + expect($emojiButton.next('.js-counter').text()).toBe('1'); + return expect($votesBlock.hasClass('hidden')).toBe(false); + }); + it('should remove the emoji when we click again', function() { + var $emojiButton, $votesBlock; + $votesBlock = $('.js-awards-block').eq(0); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + $emojiButton = $votesBlock.find('[data-emoji=heart]'); + return expect($emojiButton.length).toBe(0); + }); + return it('should decrement the emoji counter', function() { + var $emojiButton, $votesBlock; + $votesBlock = $('.js-awards-block').eq(0); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + $emojiButton = $votesBlock.find('[data-emoji=heart]'); + $emojiButton.next('.js-counter').text(5); + awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); + expect($emojiButton.length).toBe(1); + return expect($emojiButton.next('.js-counter').text()).toBe('4'); + }); + }); + describe('::getAwardUrl', function() { + return it('should return the url for request', function() { + return expect(awardsHandler.getAwardUrl()).toBe('/gitlab-org/gitlab-test/issues/8/toggle_award_emoji'); + }); + }); + describe('::addAward and ::checkMutuality', function() { + return it('should handle :+1: and :-1: mutuality', function() { + var $thumbsDownEmoji, $thumbsUpEmoji, $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsDownEmoji = $votesBlock.find('[data-emoji=thumbsdown]').parent(); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); + expect($thumbsUpEmoji.hasClass('active')).toBe(true); + expect($thumbsDownEmoji.hasClass('active')).toBe(false); + $thumbsUpEmoji.tooltip(); + $thumbsDownEmoji.tooltip(); + awardsHandler.addAward($votesBlock, awardUrl, 'thumbsdown', true); + expect($thumbsUpEmoji.hasClass('active')).toBe(false); + return expect($thumbsDownEmoji.hasClass('active')).toBe(true); + }); + }); + describe('::removeEmoji', function() { + return it('should remove emoji', function() { + var $votesBlock, awardUrl; + awardUrl = awardsHandler.getAwardUrl(); + $votesBlock = $('.js-awards-block').eq(0); + awardsHandler.addAward($votesBlock, awardUrl, 'fire', false); + expect($votesBlock.find('[data-emoji=fire]').length).toBe(1); + awardsHandler.removeEmoji($votesBlock.find('[data-emoji=fire]').closest('button')); + return expect($votesBlock.find('[data-emoji=fire]').length).toBe(0); + }); + }); + describe('search', function() { + return it('should filter the emoji', function() { + $('.js-add-award').eq(0).click(); + expect($('[data-emoji=angel]').is(':visible')).toBe(true); + expect($('[data-emoji=anger]').is(':visible')).toBe(true); + $('#emoji_search').val('ali').trigger('keyup'); + expect($('[data-emoji=angel]').is(':visible')).toBe(false); + expect($('[data-emoji=anger]').is(':visible')).toBe(false); + return expect($('[data-emoji=alien]').is(':visible')).toBe(true); + }); + }); + return describe('emoji menu', function() { + var openEmojiMenuAndAddEmoji, selector; + selector = '[data-emoji=sunglasses]'; + openEmojiMenuAndAddEmoji = function() { + var $block, $emoji, $menu; + $('.js-add-award').eq(0).click(); + $menu = $('.emoji-menu'); + $block = $('.js-awards-block'); + $emoji = $menu.find(".emoji-menu-list-item " + selector); + expect($emoji.length).toBe(1); + expect($block.find(selector).length).toBe(0); + $emoji.click(); + expect($menu.hasClass('.is-visible')).toBe(false); + return expect($block.find(selector).length).toBe(1); + }; + it('should add selected emoji to awards block', function() { + return openEmojiMenuAndAddEmoji(); + }); + return it('should remove already selected emoji', function() { + var $block, $emoji; + openEmojiMenuAndAddEmoji(); + $('.js-add-award').eq(0).click(); + $block = $('.js-awards-block'); + $emoji = $('.emoji-menu').find(".emoji-menu-list-item " + selector); + $emoji.click(); + return expect($block.find(selector).length).toBe(0); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/awards_handler_spec.js.coffee b/spec/javascripts/awards_handler_spec.js.coffee deleted file mode 100644 index d7f9c6fc076..00000000000 --- a/spec/javascripts/awards_handler_spec.js.coffee +++ /dev/null @@ -1,200 +0,0 @@ -#= require awards_handler -#= require jquery -#= require jquery.cookie -#= require ./fixtures/emoji_menu - -awardsHandler = null -window.gl or= {} -window.gon or= {} -gl.emojiAliases = -> return { '+1': 'thumbsup', '-1': 'thumbsdown' } -gon.award_menu_url = '/emojis' - - -lazyAssert = (done, assertFn) -> - - setTimeout -> # Maybe jasmine.clock here? - assertFn() - done() - , 333 - - -describe 'AwardsHandler', -> - - fixture.preload 'awards_handler.html' - - beforeEach -> - fixture.load 'awards_handler.html' - awardsHandler = new AwardsHandler - spyOn(awardsHandler, 'postEmoji').and.callFake (url, emoji, cb) => cb() - spyOn(jQuery, 'get').and.callFake (req, cb) -> cb window.emojiMenu - - - describe '::showEmojiMenu', -> - - it 'should show emoji menu when Add emoji button clicked', (done) -> - - $('.js-add-award').eq(0).click() - - lazyAssert done, -> - $emojiMenu = $ '.emoji-menu' - expect($emojiMenu.length).toBe 1 - expect($emojiMenu.hasClass('is-visible')).toBe yes - expect($emojiMenu.find('#emoji_search').length).toBe 1 - expect($('.js-awards-block.current').length).toBe 1 - - - it 'should also show emoji menu for the smiley icon in notes', (done) -> - - $('.note-action-button').click() - - lazyAssert done, -> - $emojiMenu = $ '.emoji-menu' - expect($emojiMenu.length).toBe 1 - - - it 'should remove emoji menu when body is clicked', (done) -> - - $('.js-add-award').eq(0).click() - - lazyAssert done, -> - $emojiMenu = $('.emoji-menu') - $('body').click() - expect($emojiMenu.length).toBe 1 - expect($emojiMenu.hasClass('is-visible')).toBe no - expect($('.js-awards-block.current').length).toBe 0 - - - describe '::addAwardToEmojiBar', -> - - it 'should add emoji to votes block', -> - - $votesBlock = $('.js-awards-block').eq 0 - awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no - - $emojiButton = $votesBlock.find '[data-emoji=heart]' - - expect($emojiButton.length).toBe 1 - expect($emojiButton.next('.js-counter').text()).toBe '1' - expect($votesBlock.hasClass('hidden')).toBe no - - - it 'should remove the emoji when we click again', -> - - $votesBlock = $('.js-awards-block').eq 0 - awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no - awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no - $emojiButton = $votesBlock.find '[data-emoji=heart]' - - expect($emojiButton.length).toBe 0 - - - it 'should decrement the emoji counter', -> - - $votesBlock = $('.js-awards-block').eq 0 - awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no - - $emojiButton = $votesBlock.find '[data-emoji=heart]' - $emojiButton.next('.js-counter').text 5 - - awardsHandler.addAwardToEmojiBar $votesBlock, 'heart', no - - expect($emojiButton.length).toBe 1 - expect($emojiButton.next('.js-counter').text()).toBe '4' - - - describe '::getAwardUrl', -> - - it 'should return the url for request', -> - - expect(awardsHandler.getAwardUrl()).toBe '/gitlab-org/gitlab-test/issues/8/toggle_award_emoji' - - - describe '::addAward and ::checkMutuality', -> - - it 'should handle :+1: and :-1: mutuality', -> - - awardUrl = awardsHandler.getAwardUrl() - $votesBlock = $('.js-awards-block').eq 0 - $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent() - $thumbsDownEmoji = $votesBlock.find('[data-emoji=thumbsdown]').parent() - - awardsHandler.addAward $votesBlock, awardUrl, 'thumbsup', no - - expect($thumbsUpEmoji.hasClass('active')).toBe yes - expect($thumbsDownEmoji.hasClass('active')).toBe no - - $thumbsUpEmoji.tooltip() - $thumbsDownEmoji.tooltip() - - awardsHandler.addAward $votesBlock, awardUrl, 'thumbsdown', yes - - expect($thumbsUpEmoji.hasClass('active')).toBe no - expect($thumbsDownEmoji.hasClass('active')).toBe yes - - - describe '::removeEmoji', -> - - it 'should remove emoji', -> - - awardUrl = awardsHandler.getAwardUrl() - $votesBlock = $('.js-awards-block').eq 0 - - awardsHandler.addAward $votesBlock, awardUrl, 'fire', no - expect($votesBlock.find('[data-emoji=fire]').length).toBe 1 - - awardsHandler.removeEmoji $votesBlock.find('[data-emoji=fire]').closest('button') - expect($votesBlock.find('[data-emoji=fire]').length).toBe 0 - - - describe 'search', -> - - it 'should filter the emoji', -> - - $('.js-add-award').eq(0).click() - - expect($('[data-emoji=angel]').is(':visible')).toBe yes - expect($('[data-emoji=anger]').is(':visible')).toBe yes - - $('#emoji_search').val('ali').trigger 'keyup' - - expect($('[data-emoji=angel]').is(':visible')).toBe no - expect($('[data-emoji=anger]').is(':visible')).toBe no - expect($('[data-emoji=alien]').is(':visible')).toBe yes - - - describe 'emoji menu', -> - - selector = '[data-emoji=sunglasses]' - - openEmojiMenuAndAddEmoji = -> - - $('.js-add-award').eq(0).click() - - $menu = $ '.emoji-menu' - $block = $ '.js-awards-block' - $emoji = $menu.find ".emoji-menu-list-item #{selector}" - - expect($emoji.length).toBe 1 - expect($block.find(selector).length).toBe 0 - - $emoji.click() - - expect($menu.hasClass('.is-visible')).toBe no - expect($block.find(selector).length).toBe 1 - - - it 'should add selected emoji to awards block', -> - - openEmojiMenuAndAddEmoji() - - - it 'should remove already selected emoji', -> - - openEmojiMenuAndAddEmoji() - $('.js-add-award').eq(0).click() - - $block = $ '.js-awards-block' - $emoji = $('.emoji-menu').find ".emoji-menu-list-item #{selector}" - - $emoji.click() - expect($block.find(selector).length).toBe 0 diff --git a/spec/javascripts/behaviors/autosize_spec.js b/spec/javascripts/behaviors/autosize_spec.js new file mode 100644 index 00000000000..78795f7654a --- /dev/null +++ b/spec/javascripts/behaviors/autosize_spec.js @@ -0,0 +1,21 @@ + +/*= require behaviors/autosize */ + +(function() { + describe('Autosize behavior', function() { + var load; + beforeEach(function() { + return fixture.set('<textarea class="js-autosize" style="resize: vertical"></textarea>'); + }); + it('does not overwrite the resize property', function() { + load(); + return expect($('textarea')).toHaveCss({ + resize: 'vertical' + }); + }); + return load = function() { + return $(document).trigger('page:load'); + }; + }); + +}).call(this); diff --git a/spec/javascripts/behaviors/autosize_spec.js.coffee b/spec/javascripts/behaviors/autosize_spec.js.coffee deleted file mode 100644 index 7fc1d19c35f..00000000000 --- a/spec/javascripts/behaviors/autosize_spec.js.coffee +++ /dev/null @@ -1,11 +0,0 @@ -#= require behaviors/autosize - -describe 'Autosize behavior', -> - beforeEach -> - fixture.set('<textarea class="js-autosize" style="resize: vertical"></textarea>') - - it 'does not overwrite the resize property', -> - load() - expect($('textarea')).toHaveCss(resize: 'vertical') - - load = -> $(document).trigger('page:load') diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js new file mode 100644 index 00000000000..4c52ecd903d --- /dev/null +++ b/spec/javascripts/behaviors/quick_submit_spec.js @@ -0,0 +1,93 @@ + +/*= require behaviors/quick_submit */ + +(function() { + describe('Quick Submit behavior', function() { + var keydownEvent; + fixture.preload('behaviors/quick_submit.html'); + beforeEach(function() { + fixture.load('behaviors/quick_submit.html'); + $('form').submit(function(e) { + return e.preventDefault(); + }); + return this.spies = { + submit: spyOnEvent('form', 'submit') + }; + }); + it('does not respond to other keyCodes', function() { + $('input.quick-submit-input').trigger(keydownEvent({ + keyCode: 32 + })); + return expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + it('does not respond to Enter alone', function() { + $('input.quick-submit-input').trigger(keydownEvent({ + ctrlKey: false, + metaKey: false + })); + return expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + it('does not respond to repeated events', function() { + $('input.quick-submit-input').trigger(keydownEvent({ + repeat: true + })); + return expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + it('disables submit buttons', function() { + $('textarea').trigger(keydownEvent()); + expect($('input[type=submit]')).toBeDisabled(); + return expect($('button[type=submit]')).toBeDisabled(); + }); + if (navigator.userAgent.match(/Macintosh/)) { + it('responds to Meta+Enter', function() { + $('input.quick-submit-input').trigger(keydownEvent()); + return expect(this.spies.submit).toHaveBeenTriggered(); + }); + it('excludes other modifier keys', function() { + $('input.quick-submit-input').trigger(keydownEvent({ + altKey: true + })); + $('input.quick-submit-input').trigger(keydownEvent({ + ctrlKey: true + })); + $('input.quick-submit-input').trigger(keydownEvent({ + shiftKey: true + })); + return expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + } else { + it('responds to Ctrl+Enter', function() { + $('input.quick-submit-input').trigger(keydownEvent()); + return expect(this.spies.submit).toHaveBeenTriggered(); + }); + it('excludes other modifier keys', function() { + $('input.quick-submit-input').trigger(keydownEvent({ + altKey: true + })); + $('input.quick-submit-input').trigger(keydownEvent({ + metaKey: true + })); + $('input.quick-submit-input').trigger(keydownEvent({ + shiftKey: true + })); + return expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + } + return keydownEvent = function(options) { + var defaults; + if (navigator.userAgent.match(/Macintosh/)) { + defaults = { + keyCode: 13, + metaKey: true + }; + } else { + defaults = { + keyCode: 13, + ctrlKey: true + }; + } + return $.Event('keydown', $.extend({}, defaults, options)); + }; + }); + +}).call(this); diff --git a/spec/javascripts/behaviors/quick_submit_spec.js.coffee b/spec/javascripts/behaviors/quick_submit_spec.js.coffee deleted file mode 100644 index d3b003a328a..00000000000 --- a/spec/javascripts/behaviors/quick_submit_spec.js.coffee +++ /dev/null @@ -1,70 +0,0 @@ -#= require behaviors/quick_submit - -describe 'Quick Submit behavior', -> - fixture.preload('behaviors/quick_submit.html') - - beforeEach -> - fixture.load('behaviors/quick_submit.html') - - # Prevent a form submit from moving us off the testing page - $('form').submit (e) -> e.preventDefault() - - @spies = { - submit: spyOnEvent('form', 'submit') - } - - it 'does not respond to other keyCodes', -> - $('input.quick-submit-input').trigger(keydownEvent(keyCode: 32)) - - expect(@spies.submit).not.toHaveBeenTriggered() - - it 'does not respond to Enter alone', -> - $('input.quick-submit-input').trigger(keydownEvent(ctrlKey: false, metaKey: false)) - - expect(@spies.submit).not.toHaveBeenTriggered() - - it 'does not respond to repeated events', -> - $('input.quick-submit-input').trigger(keydownEvent(repeat: true)) - - expect(@spies.submit).not.toHaveBeenTriggered() - - it 'disables submit buttons', -> - $('textarea').trigger(keydownEvent()) - - expect($('input[type=submit]')).toBeDisabled() - expect($('button[type=submit]')).toBeDisabled() - - # We cannot stub `navigator.userAgent` for CI's `rake teaspoon` task, so we'll - # only run the tests that apply to the current platform - if navigator.userAgent.match(/Macintosh/) - it 'responds to Meta+Enter', -> - $('input.quick-submit-input').trigger(keydownEvent()) - - expect(@spies.submit).toHaveBeenTriggered() - - it 'excludes other modifier keys', -> - $('input.quick-submit-input').trigger(keydownEvent(altKey: true)) - $('input.quick-submit-input').trigger(keydownEvent(ctrlKey: true)) - $('input.quick-submit-input').trigger(keydownEvent(shiftKey: true)) - - expect(@spies.submit).not.toHaveBeenTriggered() - else - it 'responds to Ctrl+Enter', -> - $('input.quick-submit-input').trigger(keydownEvent()) - - expect(@spies.submit).toHaveBeenTriggered() - - it 'excludes other modifier keys', -> - $('input.quick-submit-input').trigger(keydownEvent(altKey: true)) - $('input.quick-submit-input').trigger(keydownEvent(metaKey: true)) - $('input.quick-submit-input').trigger(keydownEvent(shiftKey: true)) - - expect(@spies.submit).not.toHaveBeenTriggered() - - keydownEvent = (options) -> - if navigator.userAgent.match(/Macintosh/) - defaults = { keyCode: 13, metaKey: true } - else - defaults = { keyCode: 13, ctrlKey: true } - - $.Event('keydown', $.extend({}, defaults, options)) diff --git a/spec/javascripts/behaviors/requires_input_spec.js b/spec/javascripts/behaviors/requires_input_spec.js new file mode 100644 index 00000000000..724c3baf989 --- /dev/null +++ b/spec/javascripts/behaviors/requires_input_spec.js @@ -0,0 +1,44 @@ + +/*= require behaviors/requires_input */ + +(function() { + describe('requiresInput', function() { + fixture.preload('behaviors/requires_input.html'); + beforeEach(function() { + return fixture.load('behaviors/requires_input.html'); + }); + it('disables submit when any field is required', function() { + $('.js-requires-input').requiresInput(); + return expect($('.submit')).toBeDisabled(); + }); + it('enables submit when no field is required', function() { + $('*[required=required]').removeAttr('required'); + $('.js-requires-input').requiresInput(); + return expect($('.submit')).not.toBeDisabled(); + }); + it('enables submit when all required fields are pre-filled', function() { + $('*[required=required]').remove(); + $('.js-requires-input').requiresInput(); + return expect($('.submit')).not.toBeDisabled(); + }); + it('enables submit when all required fields receive input', function() { + $('.js-requires-input').requiresInput(); + $('#required1').val('input1').change(); + expect($('.submit')).toBeDisabled(); + $('#optional1').val('input1').change(); + expect($('.submit')).toBeDisabled(); + $('#required2').val('input2').change(); + $('#required3').val('input3').change(); + $('#required4').val('input4').change(); + $('#required5').val('1').change(); + return expect($('.submit')).not.toBeDisabled(); + }); + return it('is called on page:load event', function() { + var spy; + spy = spyOn($.fn, 'requiresInput'); + $(document).trigger('page:load'); + return expect(spy).toHaveBeenCalled(); + }); + }); + +}).call(this); diff --git a/spec/javascripts/behaviors/requires_input_spec.js.coffee b/spec/javascripts/behaviors/requires_input_spec.js.coffee deleted file mode 100644 index 61a17632173..00000000000 --- a/spec/javascripts/behaviors/requires_input_spec.js.coffee +++ /dev/null @@ -1,49 +0,0 @@ -#= require behaviors/requires_input - -describe 'requiresInput', -> - fixture.preload('behaviors/requires_input.html') - - beforeEach -> - fixture.load('behaviors/requires_input.html') - - it 'disables submit when any field is required', -> - $('.js-requires-input').requiresInput() - - expect($('.submit')).toBeDisabled() - - it 'enables submit when no field is required', -> - $('*[required=required]').removeAttr('required') - - $('.js-requires-input').requiresInput() - - expect($('.submit')).not.toBeDisabled() - - it 'enables submit when all required fields are pre-filled', -> - $('*[required=required]').remove() - - $('.js-requires-input').requiresInput() - - expect($('.submit')).not.toBeDisabled() - - it 'enables submit when all required fields receive input', -> - $('.js-requires-input').requiresInput() - - $('#required1').val('input1').change() - expect($('.submit')).toBeDisabled() - - $('#optional1').val('input1').change() - expect($('.submit')).toBeDisabled() - - $('#required2').val('input2').change() - $('#required3').val('input3').change() - $('#required4').val('input4').change() - $('#required5').val('1').change() - - expect($('.submit')).not.toBeDisabled() - - it 'is called on page:load event', -> - spy = spyOn($.fn, 'requiresInput') - - $(document).trigger('page:load') - - expect(spy).toHaveBeenCalled() diff --git a/spec/javascripts/datetime_utility_spec.js.coffee b/spec/javascripts/datetime_utility_spec.js.coffee new file mode 100644 index 00000000000..6b9617341fe --- /dev/null +++ b/spec/javascripts/datetime_utility_spec.js.coffee @@ -0,0 +1,31 @@ +#= require lib/utils/datetime_utility + +describe 'Date time utils', -> + describe 'get day name', -> + it 'should return Sunday', -> + day = gl.utils.getDayName(new Date('07/17/2016')) + expect(day).toBe('Sunday') + + it 'should return Monday', -> + day = gl.utils.getDayName(new Date('07/18/2016')) + expect(day).toBe('Monday') + + it 'should return Tuesday', -> + day = gl.utils.getDayName(new Date('07/19/2016')) + expect(day).toBe('Tuesday') + + it 'should return Wednesday', -> + day = gl.utils.getDayName(new Date('07/20/2016')) + expect(day).toBe('Wednesday') + + it 'should return Thursday', -> + day = gl.utils.getDayName(new Date('07/21/2016')) + expect(day).toBe('Thursday') + + it 'should return Friday', -> + day = gl.utils.getDayName(new Date('07/22/2016')) + expect(day).toBe('Friday') + + it 'should return Saturday', -> + day = gl.utils.getDayName(new Date('07/23/2016')) + expect(day).toBe('Saturday') diff --git a/spec/javascripts/extensions/array_spec.js b/spec/javascripts/extensions/array_spec.js new file mode 100644 index 00000000000..eced2f6575d --- /dev/null +++ b/spec/javascripts/extensions/array_spec.js @@ -0,0 +1,22 @@ + +/*= require extensions/array */ + +(function() { + describe('Array extensions', function() { + describe('first', function() { + return it('returns the first item', function() { + var arr; + arr = [0, 1, 2, 3, 4, 5]; + return expect(arr.first()).toBe(0); + }); + }); + return describe('last', function() { + return it('returns the last item', function() { + var arr; + arr = [0, 1, 2, 3, 4, 5]; + return expect(arr.last()).toBe(5); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/extensions/array_spec.js.coffee b/spec/javascripts/extensions/array_spec.js.coffee deleted file mode 100644 index 4ceac619422..00000000000 --- a/spec/javascripts/extensions/array_spec.js.coffee +++ /dev/null @@ -1,12 +0,0 @@ -#= require extensions/array - -describe 'Array extensions', -> - describe 'first', -> - it 'returns the first item', -> - arr = [0, 1, 2, 3, 4, 5] - expect(arr.first()).toBe(0) - - describe 'last', -> - it 'returns the last item', -> - arr = [0, 1, 2, 3, 4, 5] - expect(arr.last()).toBe(5) diff --git a/spec/javascripts/extensions/jquery_spec.js b/spec/javascripts/extensions/jquery_spec.js new file mode 100644 index 00000000000..b644344b95a --- /dev/null +++ b/spec/javascripts/extensions/jquery_spec.js @@ -0,0 +1,42 @@ + +/*= require extensions/jquery */ + +(function() { + describe('jQuery extensions', function() { + describe('disable', function() { + beforeEach(function() { + return fixture.set('<input type="text" />'); + }); + it('adds the disabled attribute', function() { + var $input; + $input = $('input').first(); + $input.disable(); + return expect($input).toHaveAttr('disabled', 'disabled'); + }); + return it('adds the disabled class', function() { + var $input; + $input = $('input').first(); + $input.disable(); + return expect($input).toHaveClass('disabled'); + }); + }); + return describe('enable', function() { + beforeEach(function() { + return fixture.set('<input type="text" disabled="disabled" class="disabled" />'); + }); + it('removes the disabled attribute', function() { + var $input; + $input = $('input').first(); + $input.enable(); + return expect($input).not.toHaveAttr('disabled'); + }); + return it('removes the disabled class', function() { + var $input; + $input = $('input').first(); + $input.enable(); + return expect($input).not.toHaveClass('disabled'); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/extensions/jquery_spec.js.coffee b/spec/javascripts/extensions/jquery_spec.js.coffee deleted file mode 100644 index b10e16b7d01..00000000000 --- a/spec/javascripts/extensions/jquery_spec.js.coffee +++ /dev/null @@ -1,34 +0,0 @@ -#= require extensions/jquery - -describe 'jQuery extensions', -> - describe 'disable', -> - beforeEach -> - fixture.set '<input type="text" />' - - it 'adds the disabled attribute', -> - $input = $('input').first() - - $input.disable() - expect($input).toHaveAttr('disabled', 'disabled') - - it 'adds the disabled class', -> - $input = $('input').first() - - $input.disable() - expect($input).toHaveClass('disabled') - - describe 'enable', -> - beforeEach -> - fixture.set '<input type="text" disabled="disabled" class="disabled" />' - - it 'removes the disabled attribute', -> - $input = $('input').first() - - $input.enable() - expect($input).not.toHaveAttr('disabled') - - it 'removes the disabled class', -> - $input = $('input').first() - - $input.enable() - expect($input).not.toHaveClass('disabled') diff --git a/spec/javascripts/fixtures/emoji_menu.coffee b/spec/javascripts/fixtures/emoji_menu.coffee deleted file mode 100644 index ce1a41390d2..00000000000 --- a/spec/javascripts/fixtures/emoji_menu.coffee +++ /dev/null @@ -1,957 +0,0 @@ -window.emojiMenu = """ - <div class='emoji-menu'> - <input type="text" name="emoji_search" id="emoji_search" value="" class="emoji-search search-input form-control" /> - <div class='emoji-menu-content'> - <h5 class='emoji-menu-title'> - Emoticons - </h5> - <ul class='clearfix emoji-menu-list'> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F47D" title="alien" data-aliases="" data-emoji="alien" data-unicode-name="1F47D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F47C" title="angel" data-aliases="" data-emoji="angel" data-unicode-name="1F47C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4A2" title="anger" data-aliases="" data-emoji="anger" data-unicode-name="1F4A2"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F620" title="angry" data-aliases="" data-emoji="angry" data-unicode-name="1F620"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F627" title="anguished" data-aliases="" data-emoji="anguished" data-unicode-name="1F627"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F632" title="astonished" data-aliases="" data-emoji="astonished" data-unicode-name="1F632"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F45F" title="athletic_shoe" data-aliases="" data-emoji="athletic_shoe" data-unicode-name="1F45F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F476" title="baby" data-aliases="" data-emoji="baby" data-unicode-name="1F476"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F459" title="bikini" data-aliases="" data-emoji="bikini" data-unicode-name="1F459"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F499" title="blue_heart" data-aliases="" data-emoji="blue_heart" data-unicode-name="1F499"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F60A" title="blush" data-aliases="" data-emoji="blush" data-unicode-name="1F60A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4A5" title="boom" data-aliases="" data-emoji="boom" data-unicode-name="1F4A5"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F462" title="boot" data-aliases="" data-emoji="boot" data-unicode-name="1F462"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F647" title="bow" data-aliases="" data-emoji="bow" data-unicode-name="1F647"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F466" title="boy" data-aliases="" data-emoji="boy" data-unicode-name="1F466"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F470" title="bride_with_veil" data-aliases="" data-emoji="bride_with_veil" data-unicode-name="1F470"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4BC" title="briefcase" data-aliases="" data-emoji="briefcase" data-unicode-name="1F4BC"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F494" title="broken_heart" data-aliases="" data-emoji="broken_heart" data-unicode-name="1F494"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F464" title="bust_in_silhouette" data-aliases="" data-emoji="bust_in_silhouette" data-unicode-name="1F464"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F465" title="busts_in_silhouette" data-aliases="" data-emoji="busts_in_silhouette" data-unicode-name="1F465"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F44F" title="clap" data-aliases="" data-emoji="clap" data-unicode-name="1F44F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F302" title="closed_umbrella" data-aliases="" data-emoji="closed_umbrella" data-unicode-name="1F302"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F630" title="cold_sweat" data-aliases="" data-emoji="cold_sweat" data-unicode-name="1F630"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F616" title="confounded" data-aliases="" data-emoji="confounded" data-unicode-name="1F616"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F615" title="confused" data-aliases="" data-emoji="confused" data-unicode-name="1F615"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F477" title="construction_worker" data-aliases="" data-emoji="construction_worker" data-unicode-name="1F477"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F46E" title="cop" data-aliases="" data-emoji="cop" data-unicode-name="1F46E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F46B" title="couple" data-aliases="" data-emoji="couple" data-unicode-name="1F46B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F491" title="couple_with_heart" data-aliases="" data-emoji="couple_with_heart" data-unicode-name="1F491"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F48F" title="couplekiss" data-aliases="" data-emoji="couplekiss" data-unicode-name="1F48F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F451" title="crown" data-aliases="" data-emoji="crown" data-unicode-name="1F451"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F622" title="cry" data-aliases="" data-emoji="cry" data-unicode-name="1F622"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F63F" title="crying_cat_face" data-aliases="" data-emoji="crying_cat_face" data-unicode-name="1F63F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F498" title="cupid" data-aliases="" data-emoji="cupid" data-unicode-name="1F498"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F483" title="dancer" data-aliases="" data-emoji="dancer" data-unicode-name="1F483"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F46F" title="dancers" data-aliases="" data-emoji="dancers" data-unicode-name="1F46F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4A8" title="dash" data-aliases="" data-emoji="dash" data-unicode-name="1F4A8"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F61E" title="disappointed" data-aliases="" data-emoji="disappointed" data-unicode-name="1F61E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F625" title="disappointed_relieved" data-aliases="" data-emoji="disappointed_relieved" data-unicode-name="1F625"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4AB" title="dizzy" data-aliases="" data-emoji="dizzy" data-unicode-name="1F4AB"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F635" title="dizzy_face" data-aliases="" data-emoji="dizzy_face" data-unicode-name="1F635"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F457" title="dress" data-aliases="" data-emoji="dress" data-unicode-name="1F457"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4A7" title="droplet" data-aliases="" data-emoji="droplet" data-unicode-name="1F4A7"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F442" title="ear" data-aliases="" data-emoji="ear" data-unicode-name="1F442"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F611" title="expressionless" data-aliases="" data-emoji="expressionless" data-unicode-name="1F611"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F453" title="eyeglasses" data-aliases="" data-emoji="eyeglasses" data-unicode-name="1F453"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F440" title="eyes" data-aliases="" data-emoji="eyes" data-unicode-name="1F440"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F46A" title="family" data-aliases="" data-emoji="family" data-unicode-name="1F46A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F628" title="fearful" data-aliases="" data-emoji="fearful" data-unicode-name="1F628"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F525" title="fire" data-aliases=":flame:" data-emoji="fire" data-unicode-name="1F525"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-270A" title="fist" data-aliases="" data-emoji="fist" data-unicode-name="270A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F633" title="flushed" data-aliases="" data-emoji="flushed" data-unicode-name="1F633"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F463" title="footprints" data-aliases="" data-emoji="footprints" data-unicode-name="1F463"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F626" title="frowning" data-aliases=":anguished:" data-emoji="frowning" data-unicode-name="1F626"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F48E" title="gem" data-aliases="" data-emoji="gem" data-unicode-name="1F48E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F467" title="girl" data-aliases="" data-emoji="girl" data-unicode-name="1F467"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F49A" title="green_heart" data-aliases="" data-emoji="green_heart" data-unicode-name="1F49A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F62C" title="grimacing" data-aliases="" data-emoji="grimacing" data-unicode-name="1F62C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F601" title="grin" data-aliases="" data-emoji="grin" data-unicode-name="1F601"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F600" title="grinning" data-aliases="" data-emoji="grinning" data-unicode-name="1F600"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F482" title="guardsman" data-aliases="" data-emoji="guardsman" data-unicode-name="1F482"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F487" title="haircut" data-aliases="" data-emoji="haircut" data-unicode-name="1F487"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F45C" title="handbag" data-aliases="" data-emoji="handbag" data-unicode-name="1F45C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F649" title="hear_no_evil" data-aliases="" data-emoji="hear_no_evil" data-unicode-name="1F649"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-2764" title="heart" data-aliases="" data-emoji="heart" data-unicode-name="2764"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F60D" title="heart_eyes" data-aliases="" data-emoji="heart_eyes" data-unicode-name="1F60D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F63B" title="heart_eyes_cat" data-aliases="" data-emoji="heart_eyes_cat" data-unicode-name="1F63B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F493" title="heartbeat" data-aliases="" data-emoji="heartbeat" data-unicode-name="1F493"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F497" title="heartpulse" data-aliases="" data-emoji="heartpulse" data-unicode-name="1F497"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F460" title="high_heel" data-aliases="" data-emoji="high_heel" data-unicode-name="1F460"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F62F" title="hushed" data-aliases="" data-emoji="hushed" data-unicode-name="1F62F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F47F" title="imp" data-aliases="" data-emoji="imp" data-unicode-name="1F47F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F481" title="information_desk_person" data-aliases="" data-emoji="information_desk_person" data-unicode-name="1F481"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F607" title="innocent" data-aliases="" data-emoji="innocent" data-unicode-name="1F607"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F47A" title="japanese_goblin" data-aliases="" data-emoji="japanese_goblin" data-unicode-name="1F47A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F479" title="japanese_ogre" data-aliases="" data-emoji="japanese_ogre" data-unicode-name="1F479"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F456" title="jeans" data-aliases="" data-emoji="jeans" data-unicode-name="1F456"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F602" title="joy" data-aliases="" data-emoji="joy" data-unicode-name="1F602"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F639" title="joy_cat" data-aliases="" data-emoji="joy_cat" data-unicode-name="1F639"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F458" title="kimono" data-aliases="" data-emoji="kimono" data-unicode-name="1F458"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F48B" title="kiss" data-aliases="" data-emoji="kiss" data-unicode-name="1F48B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F617" title="kissing" data-aliases="" data-emoji="kissing" data-unicode-name="1F617"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F63D" title="kissing_cat" data-aliases="" data-emoji="kissing_cat" data-unicode-name="1F63D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F61A" title="kissing_closed_eyes" data-aliases="" data-emoji="kissing_closed_eyes" data-unicode-name="1F61A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F618" title="kissing_heart" data-aliases="" data-emoji="kissing_heart" data-unicode-name="1F618"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F619" title="kissing_smiling_eyes" data-aliases="" data-emoji="kissing_smiling_eyes" data-unicode-name="1F619"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F606" title="laughing" data-aliases=":satisfied:" data-emoji="laughing" data-unicode-name="1F606"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F444" title="lips" data-aliases="" data-emoji="lips" data-unicode-name="1F444"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F484" title="lipstick" data-aliases="" data-emoji="lipstick" data-unicode-name="1F484"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F48C" title="love_letter" data-aliases="" data-emoji="love_letter" data-unicode-name="1F48C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F468" title="man" data-aliases="" data-emoji="man" data-unicode-name="1F468"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F472" title="man_with_gua_pi_mao" data-aliases="" data-emoji="man_with_gua_pi_mao" data-unicode-name="1F472"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F473" title="man_with_turban" data-aliases="" data-emoji="man_with_turban" data-unicode-name="1F473"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F45E" title="mans_shoe" data-aliases="" data-emoji="mans_shoe" data-unicode-name="1F45E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F637" title="mask" data-aliases="" data-emoji="mask" data-unicode-name="1F637"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F486" title="massage" data-aliases="" data-emoji="massage" data-unicode-name="1F486"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4AA" title="muscle" data-aliases="" data-emoji="muscle" data-unicode-name="1F4AA"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F485" title="nail_care" data-aliases="" data-emoji="nail_care" data-unicode-name="1F485"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F454" title="necktie" data-aliases="" data-emoji="necktie" data-unicode-name="1F454"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F610" title="neutral_face" data-aliases="" data-emoji="neutral_face" data-unicode-name="1F610"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F645" title="no_good" data-aliases="" data-emoji="no_good" data-unicode-name="1F645"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F636" title="no_mouth" data-aliases="" data-emoji="no_mouth" data-unicode-name="1F636"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F443" title="nose" data-aliases="" data-emoji="nose" data-unicode-name="1F443"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F44C" title="ok_hand" data-aliases="" data-emoji="ok_hand" data-unicode-name="1F44C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F646" title="ok_woman" data-aliases="" data-emoji="ok_woman" data-unicode-name="1F646"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F474" title="older_man" data-aliases="" data-emoji="older_man" data-unicode-name="1F474"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F475" title="older_woman" data-aliases=":grandma:" data-emoji="older_woman" data-unicode-name="1F475"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F450" title="open_hands" data-aliases="" data-emoji="open_hands" data-unicode-name="1F450"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F62E" title="open_mouth" data-aliases="" data-emoji="open_mouth" data-unicode-name="1F62E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F614" title="pensive" data-aliases="" data-emoji="pensive" data-unicode-name="1F614"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F623" title="persevere" data-aliases="" data-emoji="persevere" data-unicode-name="1F623"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F64D" title="person_frowning" data-aliases="" data-emoji="person_frowning" data-unicode-name="1F64D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F471" title="person_with_blond_hair" data-aliases="" data-emoji="person_with_blond_hair" data-unicode-name="1F471"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F64E" title="person_with_pouting_face" data-aliases="" data-emoji="person_with_pouting_face" data-unicode-name="1F64E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F447" title="point_down" data-aliases="" data-emoji="point_down" data-unicode-name="1F447"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F448" title="point_left" data-aliases="" data-emoji="point_left" data-unicode-name="1F448"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F449" title="point_right" data-aliases="" data-emoji="point_right" data-unicode-name="1F449"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-261D" title="point_up" data-aliases="" data-emoji="point_up" data-unicode-name="261D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F446" title="point_up_2" data-aliases="" data-emoji="point_up_2" data-unicode-name="1F446"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4A9" title="poop" data-aliases=":shit: :hankey: :poo:" data-emoji="poop" data-unicode-name="1F4A9"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F45D" title="pouch" data-aliases="" data-emoji="pouch" data-unicode-name="1F45D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F63E" title="pouting_cat" data-aliases="" data-emoji="pouting_cat" data-unicode-name="1F63E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F64F" title="pray" data-aliases="" data-emoji="pray" data-unicode-name="1F64F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F478" title="princess" data-aliases="" data-emoji="princess" data-unicode-name="1F478"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F44A" title="punch" data-aliases="" data-emoji="punch" data-unicode-name="1F44A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F49C" title="purple_heart" data-aliases="" data-emoji="purple_heart" data-unicode-name="1F49C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F45B" title="purse" data-aliases="" data-emoji="purse" data-unicode-name="1F45B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F621" title="rage" data-aliases="" data-emoji="rage" data-unicode-name="1F621"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-270B" title="raised_hand" data-aliases="" data-emoji="raised_hand" data-unicode-name="270B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F64C" title="raised_hands" data-aliases="" data-emoji="raised_hands" data-unicode-name="1F64C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F64B" title="raising_hand" data-aliases="" data-emoji="raising_hand" data-unicode-name="1F64B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-263A" title="relaxed" data-aliases="" data-emoji="relaxed" data-unicode-name="263A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F60C" title="relieved" data-aliases="" data-emoji="relieved" data-unicode-name="1F60C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F49E" title="revolving_hearts" data-aliases="" data-emoji="revolving_hearts" data-unicode-name="1F49E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F380" title="ribbon" data-aliases="" data-emoji="ribbon" data-unicode-name="1F380"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F48D" title="ring" data-aliases="" data-emoji="ring" data-unicode-name="1F48D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F3C3" title="runner" data-aliases="" data-emoji="runner" data-unicode-name="1F3C3"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F3BD" title="running_shirt_with_sash" data-aliases="" data-emoji="running_shirt_with_sash" data-unicode-name="1F3BD"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F461" title="sandal" data-aliases="" data-emoji="sandal" data-unicode-name="1F461"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F631" title="scream" data-aliases="" data-emoji="scream" data-unicode-name="1F631"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F640" title="scream_cat" data-aliases="" data-emoji="scream_cat" data-unicode-name="1F640"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F648" title="see_no_evil" data-aliases="" data-emoji="see_no_evil" data-unicode-name="1F648"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F455" title="shirt" data-aliases="" data-emoji="shirt" data-unicode-name="1F455"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F480" title="skull" data-aliases=":skeleton:" data-emoji="skull" data-unicode-name="1F480"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F634" title="sleeping" data-aliases="" data-emoji="sleeping" data-unicode-name="1F634"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F62A" title="sleepy" data-aliases="" data-emoji="sleepy" data-unicode-name="1F62A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F604" title="smile" data-aliases="" data-emoji="smile" data-unicode-name="1F604"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F638" title="smile_cat" data-aliases="" data-emoji="smile_cat" data-unicode-name="1F638"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F603" title="smiley" data-aliases="" data-emoji="smiley" data-unicode-name="1F603"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F63A" title="smiley_cat" data-aliases="" data-emoji="smiley_cat" data-unicode-name="1F63A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F608" title="smiling_imp" data-aliases="" data-emoji="smiling_imp" data-unicode-name="1F608"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F60F" title="smirk" data-aliases="" data-emoji="smirk" data-unicode-name="1F60F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F63C" title="smirk_cat" data-aliases="" data-emoji="smirk_cat" data-unicode-name="1F63C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F62D" title="sob" data-aliases="" data-emoji="sob" data-unicode-name="1F62D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-2728" title="sparkles" data-aliases="" data-emoji="sparkles" data-unicode-name="2728"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F496" title="sparkling_heart" data-aliases="" data-emoji="sparkling_heart" data-unicode-name="1F496"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F64A" title="speak_no_evil" data-aliases="" data-emoji="speak_no_evil" data-unicode-name="1F64A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4AC" title="speech_balloon" data-aliases="" data-emoji="speech_balloon" data-unicode-name="1F4AC"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F31F" title="star2" data-aliases="" data-emoji="star2" data-unicode-name="1F31F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F61B" title="stuck_out_tongue" data-aliases="" data-emoji="stuck_out_tongue" data-unicode-name="1F61B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F61D" title="stuck_out_tongue_closed_eyes" data-aliases="" data-emoji="stuck_out_tongue_closed_eyes" data-unicode-name="1F61D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F61C" title="stuck_out_tongue_winking_eye" data-aliases="" data-emoji="stuck_out_tongue_winking_eye" data-unicode-name="1F61C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F60E" title="sunglasses" data-aliases="" data-emoji="sunglasses" data-unicode-name="1F60E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F613" title="sweat" data-aliases="" data-emoji="sweat" data-unicode-name="1F613"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4A6" title="sweat_drops" data-aliases="" data-emoji="sweat_drops" data-unicode-name="1F4A6"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F605" title="sweat_smile" data-aliases="" data-emoji="sweat_smile" data-unicode-name="1F605"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4AD" title="thought_balloon" data-aliases="" data-emoji="thought_balloon" data-unicode-name="1F4AD"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F44E" title="thumbsdown" data-aliases=":-1:" data-emoji="thumbsdown" data-unicode-name="1F44E"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F44D" title="thumbsup" data-aliases=":+1:" data-emoji="thumbsup" data-unicode-name="1F44D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F62B" title="tired_face" data-aliases="" data-emoji="tired_face" data-unicode-name="1F62B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F445" title="tongue" data-aliases="" data-emoji="tongue" data-unicode-name="1F445"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F3A9" title="tophat" data-aliases="" data-emoji="tophat" data-unicode-name="1F3A9"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F624" title="triumph" data-aliases="" data-emoji="triumph" data-unicode-name="1F624"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F495" title="two_hearts" data-aliases="" data-emoji="two_hearts" data-unicode-name="1F495"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F46C" title="two_men_holding_hands" data-aliases="" data-emoji="two_men_holding_hands" data-unicode-name="1F46C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F46D" title="two_women_holding_hands" data-aliases="" data-emoji="two_women_holding_hands" data-unicode-name="1F46D"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F612" title="unamused" data-aliases="" data-emoji="unamused" data-unicode-name="1F612"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-270C" title="v" data-aliases="" data-emoji="v" data-unicode-name="270C"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F6B6" title="walking" data-aliases="" data-emoji="walking" data-unicode-name="1F6B6"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F44B" title="wave" data-aliases="" data-emoji="wave" data-unicode-name="1F44B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F629" title="weary" data-aliases="" data-emoji="weary" data-unicode-name="1F629"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F609" title="wink" data-aliases="" data-emoji="wink" data-unicode-name="1F609"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F469" title="woman" data-aliases="" data-emoji="woman" data-unicode-name="1F469"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F45A" title="womans_clothes" data-aliases="" data-emoji="womans_clothes" data-unicode-name="1F45A"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F452" title="womans_hat" data-aliases="" data-emoji="womans_hat" data-unicode-name="1F452"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F61F" title="worried" data-aliases="" data-emoji="worried" data-unicode-name="1F61F"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F49B" title="yellow_heart" data-aliases="" data-emoji="yellow_heart" data-unicode-name="1F49B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F60B" title="yum" data-aliases="" data-emoji="yum" data-unicode-name="1F60B"></div> - </button> - </li> - <li class='pull-left text-center emoji-menu-list-item'> - <button class='emoji-menu-btn text-center js-emoji-btn' type='button'> - <div class="icon emoji-icon emoji-1F4A4" title="zzz" data-aliases="" data-emoji="zzz" data-unicode-name="1F4A4"></div> - </button> - </li> - </ul> - </div> - </div> -""" diff --git a/spec/javascripts/fixtures/emoji_menu.js b/spec/javascripts/fixtures/emoji_menu.js new file mode 100644 index 00000000000..99e3f7247bd --- /dev/null +++ b/spec/javascripts/fixtures/emoji_menu.js @@ -0,0 +1,4 @@ +(function() { + window.emojiMenu = "<div class='emoji-menu'>\n <input type=\"text\" name=\"emoji_search\" id=\"emoji_search\" value=\"\" class=\"emoji-search search-input form-control\" />\n <div class='emoji-menu-content'>\n <h5 class='emoji-menu-title'>\n Emoticons\n </h5>\n <ul class='clearfix emoji-menu-list'>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F47D\" title=\"alien\" data-aliases=\"\" data-emoji=\"alien\" data-unicode-name=\"1F47D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F47C\" title=\"angel\" data-aliases=\"\" data-emoji=\"angel\" data-unicode-name=\"1F47C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A2\" title=\"anger\" data-aliases=\"\" data-emoji=\"anger\" data-unicode-name=\"1F4A2\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F620\" title=\"angry\" data-aliases=\"\" data-emoji=\"angry\" data-unicode-name=\"1F620\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F627\" title=\"anguished\" data-aliases=\"\" data-emoji=\"anguished\" data-unicode-name=\"1F627\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F632\" title=\"astonished\" data-aliases=\"\" data-emoji=\"astonished\" data-unicode-name=\"1F632\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45F\" title=\"athletic_shoe\" data-aliases=\"\" data-emoji=\"athletic_shoe\" data-unicode-name=\"1F45F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F476\" title=\"baby\" data-aliases=\"\" data-emoji=\"baby\" data-unicode-name=\"1F476\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F459\" title=\"bikini\" data-aliases=\"\" data-emoji=\"bikini\" data-unicode-name=\"1F459\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F499\" title=\"blue_heart\" data-aliases=\"\" data-emoji=\"blue_heart\" data-unicode-name=\"1F499\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60A\" title=\"blush\" data-aliases=\"\" data-emoji=\"blush\" data-unicode-name=\"1F60A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A5\" title=\"boom\" data-aliases=\"\" data-emoji=\"boom\" data-unicode-name=\"1F4A5\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F462\" title=\"boot\" data-aliases=\"\" data-emoji=\"boot\" data-unicode-name=\"1F462\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F647\" title=\"bow\" data-aliases=\"\" data-emoji=\"bow\" data-unicode-name=\"1F647\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F466\" title=\"boy\" data-aliases=\"\" data-emoji=\"boy\" data-unicode-name=\"1F466\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F470\" title=\"bride_with_veil\" data-aliases=\"\" data-emoji=\"bride_with_veil\" data-unicode-name=\"1F470\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4BC\" title=\"briefcase\" data-aliases=\"\" data-emoji=\"briefcase\" data-unicode-name=\"1F4BC\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F494\" title=\"broken_heart\" data-aliases=\"\" data-emoji=\"broken_heart\" data-unicode-name=\"1F494\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F464\" title=\"bust_in_silhouette\" data-aliases=\"\" data-emoji=\"bust_in_silhouette\" data-unicode-name=\"1F464\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F465\" title=\"busts_in_silhouette\" data-aliases=\"\" data-emoji=\"busts_in_silhouette\" data-unicode-name=\"1F465\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44F\" title=\"clap\" data-aliases=\"\" data-emoji=\"clap\" data-unicode-name=\"1F44F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F302\" title=\"closed_umbrella\" data-aliases=\"\" data-emoji=\"closed_umbrella\" data-unicode-name=\"1F302\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F630\" title=\"cold_sweat\" data-aliases=\"\" data-emoji=\"cold_sweat\" data-unicode-name=\"1F630\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F616\" title=\"confounded\" data-aliases=\"\" data-emoji=\"confounded\" data-unicode-name=\"1F616\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F615\" title=\"confused\" data-aliases=\"\" data-emoji=\"confused\" data-unicode-name=\"1F615\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F477\" title=\"construction_worker\" data-aliases=\"\" data-emoji=\"construction_worker\" data-unicode-name=\"1F477\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46E\" title=\"cop\" data-aliases=\"\" data-emoji=\"cop\" data-unicode-name=\"1F46E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46B\" title=\"couple\" data-aliases=\"\" data-emoji=\"couple\" data-unicode-name=\"1F46B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F491\" title=\"couple_with_heart\" data-aliases=\"\" data-emoji=\"couple_with_heart\" data-unicode-name=\"1F491\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48F\" title=\"couplekiss\" data-aliases=\"\" data-emoji=\"couplekiss\" data-unicode-name=\"1F48F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F451\" title=\"crown\" data-aliases=\"\" data-emoji=\"crown\" data-unicode-name=\"1F451\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F622\" title=\"cry\" data-aliases=\"\" data-emoji=\"cry\" data-unicode-name=\"1F622\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63F\" title=\"crying_cat_face\" data-aliases=\"\" data-emoji=\"crying_cat_face\" data-unicode-name=\"1F63F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F498\" title=\"cupid\" data-aliases=\"\" data-emoji=\"cupid\" data-unicode-name=\"1F498\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F483\" title=\"dancer\" data-aliases=\"\" data-emoji=\"dancer\" data-unicode-name=\"1F483\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46F\" title=\"dancers\" data-aliases=\"\" data-emoji=\"dancers\" data-unicode-name=\"1F46F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A8\" title=\"dash\" data-aliases=\"\" data-emoji=\"dash\" data-unicode-name=\"1F4A8\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61E\" title=\"disappointed\" data-aliases=\"\" data-emoji=\"disappointed\" data-unicode-name=\"1F61E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F625\" title=\"disappointed_relieved\" data-aliases=\"\" data-emoji=\"disappointed_relieved\" data-unicode-name=\"1F625\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4AB\" title=\"dizzy\" data-aliases=\"\" data-emoji=\"dizzy\" data-unicode-name=\"1F4AB\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F635\" title=\"dizzy_face\" data-aliases=\"\" data-emoji=\"dizzy_face\" data-unicode-name=\"1F635\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F457\" title=\"dress\" data-aliases=\"\" data-emoji=\"dress\" data-unicode-name=\"1F457\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A7\" title=\"droplet\" data-aliases=\"\" data-emoji=\"droplet\" data-unicode-name=\"1F4A7\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F442\" title=\"ear\" data-aliases=\"\" data-emoji=\"ear\" data-unicode-name=\"1F442\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F611\" title=\"expressionless\" data-aliases=\"\" data-emoji=\"expressionless\" data-unicode-name=\"1F611\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F453\" title=\"eyeglasses\" data-aliases=\"\" data-emoji=\"eyeglasses\" data-unicode-name=\"1F453\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F440\" title=\"eyes\" data-aliases=\"\" data-emoji=\"eyes\" data-unicode-name=\"1F440\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46A\" title=\"family\" data-aliases=\"\" data-emoji=\"family\" data-unicode-name=\"1F46A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F628\" title=\"fearful\" data-aliases=\"\" data-emoji=\"fearful\" data-unicode-name=\"1F628\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F525\" title=\"fire\" data-aliases=\":flame:\" data-emoji=\"fire\" data-unicode-name=\"1F525\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-270A\" title=\"fist\" data-aliases=\"\" data-emoji=\"fist\" data-unicode-name=\"270A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F633\" title=\"flushed\" data-aliases=\"\" data-emoji=\"flushed\" data-unicode-name=\"1F633\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F463\" title=\"footprints\" data-aliases=\"\" data-emoji=\"footprints\" data-unicode-name=\"1F463\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F626\" title=\"frowning\" data-aliases=\":anguished:\" data-emoji=\"frowning\" data-unicode-name=\"1F626\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48E\" title=\"gem\" data-aliases=\"\" data-emoji=\"gem\" data-unicode-name=\"1F48E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F467\" title=\"girl\" data-aliases=\"\" data-emoji=\"girl\" data-unicode-name=\"1F467\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F49A\" title=\"green_heart\" data-aliases=\"\" data-emoji=\"green_heart\" data-unicode-name=\"1F49A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62C\" title=\"grimacing\" data-aliases=\"\" data-emoji=\"grimacing\" data-unicode-name=\"1F62C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F601\" title=\"grin\" data-aliases=\"\" data-emoji=\"grin\" data-unicode-name=\"1F601\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F600\" title=\"grinning\" data-aliases=\"\" data-emoji=\"grinning\" data-unicode-name=\"1F600\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F482\" title=\"guardsman\" data-aliases=\"\" data-emoji=\"guardsman\" data-unicode-name=\"1F482\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F487\" title=\"haircut\" data-aliases=\"\" data-emoji=\"haircut\" data-unicode-name=\"1F487\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45C\" title=\"handbag\" data-aliases=\"\" data-emoji=\"handbag\" data-unicode-name=\"1F45C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F649\" title=\"hear_no_evil\" data-aliases=\"\" data-emoji=\"hear_no_evil\" data-unicode-name=\"1F649\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-2764\" title=\"heart\" data-aliases=\"\" data-emoji=\"heart\" data-unicode-name=\"2764\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60D\" title=\"heart_eyes\" data-aliases=\"\" data-emoji=\"heart_eyes\" data-unicode-name=\"1F60D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63B\" title=\"heart_eyes_cat\" data-aliases=\"\" data-emoji=\"heart_eyes_cat\" data-unicode-name=\"1F63B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F493\" title=\"heartbeat\" data-aliases=\"\" data-emoji=\"heartbeat\" data-unicode-name=\"1F493\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F497\" title=\"heartpulse\" data-aliases=\"\" data-emoji=\"heartpulse\" data-unicode-name=\"1F497\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F460\" title=\"high_heel\" data-aliases=\"\" data-emoji=\"high_heel\" data-unicode-name=\"1F460\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62F\" title=\"hushed\" data-aliases=\"\" data-emoji=\"hushed\" data-unicode-name=\"1F62F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F47F\" title=\"imp\" data-aliases=\"\" data-emoji=\"imp\" data-unicode-name=\"1F47F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F481\" title=\"information_desk_person\" data-aliases=\"\" data-emoji=\"information_desk_person\" data-unicode-name=\"1F481\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F607\" title=\"innocent\" data-aliases=\"\" data-emoji=\"innocent\" data-unicode-name=\"1F607\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F47A\" title=\"japanese_goblin\" data-aliases=\"\" data-emoji=\"japanese_goblin\" data-unicode-name=\"1F47A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F479\" title=\"japanese_ogre\" data-aliases=\"\" data-emoji=\"japanese_ogre\" data-unicode-name=\"1F479\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F456\" title=\"jeans\" data-aliases=\"\" data-emoji=\"jeans\" data-unicode-name=\"1F456\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F602\" title=\"joy\" data-aliases=\"\" data-emoji=\"joy\" data-unicode-name=\"1F602\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F639\" title=\"joy_cat\" data-aliases=\"\" data-emoji=\"joy_cat\" data-unicode-name=\"1F639\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F458\" title=\"kimono\" data-aliases=\"\" data-emoji=\"kimono\" data-unicode-name=\"1F458\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48B\" title=\"kiss\" data-aliases=\"\" data-emoji=\"kiss\" data-unicode-name=\"1F48B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F617\" title=\"kissing\" data-aliases=\"\" data-emoji=\"kissing\" data-unicode-name=\"1F617\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63D\" title=\"kissing_cat\" data-aliases=\"\" data-emoji=\"kissing_cat\" data-unicode-name=\"1F63D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61A\" title=\"kissing_closed_eyes\" data-aliases=\"\" data-emoji=\"kissing_closed_eyes\" data-unicode-name=\"1F61A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F618\" title=\"kissing_heart\" data-aliases=\"\" data-emoji=\"kissing_heart\" data-unicode-name=\"1F618\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F619\" title=\"kissing_smiling_eyes\" data-aliases=\"\" data-emoji=\"kissing_smiling_eyes\" data-unicode-name=\"1F619\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F606\" title=\"laughing\" data-aliases=\":satisfied:\" data-emoji=\"laughing\" data-unicode-name=\"1F606\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F444\" title=\"lips\" data-aliases=\"\" data-emoji=\"lips\" data-unicode-name=\"1F444\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F484\" title=\"lipstick\" data-aliases=\"\" data-emoji=\"lipstick\" data-unicode-name=\"1F484\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48C\" title=\"love_letter\" data-aliases=\"\" data-emoji=\"love_letter\" data-unicode-name=\"1F48C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F468\" title=\"man\" data-aliases=\"\" data-emoji=\"man\" data-unicode-name=\"1F468\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F472\" title=\"man_with_gua_pi_mao\" data-aliases=\"\" data-emoji=\"man_with_gua_pi_mao\" data-unicode-name=\"1F472\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F473\" title=\"man_with_turban\" data-aliases=\"\" data-emoji=\"man_with_turban\" data-unicode-name=\"1F473\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45E\" title=\"mans_shoe\" data-aliases=\"\" data-emoji=\"mans_shoe\" data-unicode-name=\"1F45E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F637\" title=\"mask\" data-aliases=\"\" data-emoji=\"mask\" data-unicode-name=\"1F637\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F486\" title=\"massage\" data-aliases=\"\" data-emoji=\"massage\" data-unicode-name=\"1F486\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4AA\" title=\"muscle\" data-aliases=\"\" data-emoji=\"muscle\" data-unicode-name=\"1F4AA\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F485\" title=\"nail_care\" data-aliases=\"\" data-emoji=\"nail_care\" data-unicode-name=\"1F485\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F454\" title=\"necktie\" data-aliases=\"\" data-emoji=\"necktie\" data-unicode-name=\"1F454\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F610\" title=\"neutral_face\" data-aliases=\"\" data-emoji=\"neutral_face\" data-unicode-name=\"1F610\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F645\" title=\"no_good\" data-aliases=\"\" data-emoji=\"no_good\" data-unicode-name=\"1F645\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F636\" title=\"no_mouth\" data-aliases=\"\" data-emoji=\"no_mouth\" data-unicode-name=\"1F636\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F443\" title=\"nose\" data-aliases=\"\" data-emoji=\"nose\" data-unicode-name=\"1F443\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44C\" title=\"ok_hand\" data-aliases=\"\" data-emoji=\"ok_hand\" data-unicode-name=\"1F44C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F646\" title=\"ok_woman\" data-aliases=\"\" data-emoji=\"ok_woman\" data-unicode-name=\"1F646\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F474\" title=\"older_man\" data-aliases=\"\" data-emoji=\"older_man\" data-unicode-name=\"1F474\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F475\" title=\"older_woman\" data-aliases=\":grandma:\" data-emoji=\"older_woman\" data-unicode-name=\"1F475\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F450\" title=\"open_hands\" data-aliases=\"\" data-emoji=\"open_hands\" data-unicode-name=\"1F450\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62E\" title=\"open_mouth\" data-aliases=\"\" data-emoji=\"open_mouth\" data-unicode-name=\"1F62E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F614\" title=\"pensive\" data-aliases=\"\" data-emoji=\"pensive\" data-unicode-name=\"1F614\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F623\" title=\"persevere\" data-aliases=\"\" data-emoji=\"persevere\" data-unicode-name=\"1F623\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64D\" title=\"person_frowning\" data-aliases=\"\" data-emoji=\"person_frowning\" data-unicode-name=\"1F64D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F471\" title=\"person_with_blond_hair\" data-aliases=\"\" data-emoji=\"person_with_blond_hair\" data-unicode-name=\"1F471\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64E\" title=\"person_with_pouting_face\" data-aliases=\"\" data-emoji=\"person_with_pouting_face\" data-unicode-name=\"1F64E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F447\" title=\"point_down\" data-aliases=\"\" data-emoji=\"point_down\" data-unicode-name=\"1F447\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F448\" title=\"point_left\" data-aliases=\"\" data-emoji=\"point_left\" data-unicode-name=\"1F448\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F449\" title=\"point_right\" data-aliases=\"\" data-emoji=\"point_right\" data-unicode-name=\"1F449\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-261D\" title=\"point_up\" data-aliases=\"\" data-emoji=\"point_up\" data-unicode-name=\"261D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F446\" title=\"point_up_2\" data-aliases=\"\" data-emoji=\"point_up_2\" data-unicode-name=\"1F446\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A9\" title=\"poop\" data-aliases=\":shit: :hankey: :poo:\" data-emoji=\"poop\" data-unicode-name=\"1F4A9\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45D\" title=\"pouch\" data-aliases=\"\" data-emoji=\"pouch\" data-unicode-name=\"1F45D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63E\" title=\"pouting_cat\" data-aliases=\"\" data-emoji=\"pouting_cat\" data-unicode-name=\"1F63E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64F\" title=\"pray\" data-aliases=\"\" data-emoji=\"pray\" data-unicode-name=\"1F64F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F478\" title=\"princess\" data-aliases=\"\" data-emoji=\"princess\" data-unicode-name=\"1F478\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44A\" title=\"punch\" data-aliases=\"\" data-emoji=\"punch\" data-unicode-name=\"1F44A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F49C\" title=\"purple_heart\" data-aliases=\"\" data-emoji=\"purple_heart\" data-unicode-name=\"1F49C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45B\" title=\"purse\" data-aliases=\"\" data-emoji=\"purse\" data-unicode-name=\"1F45B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F621\" title=\"rage\" data-aliases=\"\" data-emoji=\"rage\" data-unicode-name=\"1F621\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-270B\" title=\"raised_hand\" data-aliases=\"\" data-emoji=\"raised_hand\" data-unicode-name=\"270B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64C\" title=\"raised_hands\" data-aliases=\"\" data-emoji=\"raised_hands\" data-unicode-name=\"1F64C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64B\" title=\"raising_hand\" data-aliases=\"\" data-emoji=\"raising_hand\" data-unicode-name=\"1F64B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-263A\" title=\"relaxed\" data-aliases=\"\" data-emoji=\"relaxed\" data-unicode-name=\"263A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60C\" title=\"relieved\" data-aliases=\"\" data-emoji=\"relieved\" data-unicode-name=\"1F60C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F49E\" title=\"revolving_hearts\" data-aliases=\"\" data-emoji=\"revolving_hearts\" data-unicode-name=\"1F49E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F380\" title=\"ribbon\" data-aliases=\"\" data-emoji=\"ribbon\" data-unicode-name=\"1F380\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48D\" title=\"ring\" data-aliases=\"\" data-emoji=\"ring\" data-unicode-name=\"1F48D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F3C3\" title=\"runner\" data-aliases=\"\" data-emoji=\"runner\" data-unicode-name=\"1F3C3\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F3BD\" title=\"running_shirt_with_sash\" data-aliases=\"\" data-emoji=\"running_shirt_with_sash\" data-unicode-name=\"1F3BD\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F461\" title=\"sandal\" data-aliases=\"\" data-emoji=\"sandal\" data-unicode-name=\"1F461\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F631\" title=\"scream\" data-aliases=\"\" data-emoji=\"scream\" data-unicode-name=\"1F631\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F640\" title=\"scream_cat\" data-aliases=\"\" data-emoji=\"scream_cat\" data-unicode-name=\"1F640\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F648\" title=\"see_no_evil\" data-aliases=\"\" data-emoji=\"see_no_evil\" data-unicode-name=\"1F648\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F455\" title=\"shirt\" data-aliases=\"\" data-emoji=\"shirt\" data-unicode-name=\"1F455\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F480\" title=\"skull\" data-aliases=\":skeleton:\" data-emoji=\"skull\" data-unicode-name=\"1F480\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F634\" title=\"sleeping\" data-aliases=\"\" data-emoji=\"sleeping\" data-unicode-name=\"1F634\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62A\" title=\"sleepy\" data-aliases=\"\" data-emoji=\"sleepy\" data-unicode-name=\"1F62A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F604\" title=\"smile\" data-aliases=\"\" data-emoji=\"smile\" data-unicode-name=\"1F604\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F638\" title=\"smile_cat\" data-aliases=\"\" data-emoji=\"smile_cat\" data-unicode-name=\"1F638\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F603\" title=\"smiley\" data-aliases=\"\" data-emoji=\"smiley\" data-unicode-name=\"1F603\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63A\" title=\"smiley_cat\" data-aliases=\"\" data-emoji=\"smiley_cat\" data-unicode-name=\"1F63A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F608\" title=\"smiling_imp\" data-aliases=\"\" data-emoji=\"smiling_imp\" data-unicode-name=\"1F608\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60F\" title=\"smirk\" data-aliases=\"\" data-emoji=\"smirk\" data-unicode-name=\"1F60F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63C\" title=\"smirk_cat\" data-aliases=\"\" data-emoji=\"smirk_cat\" data-unicode-name=\"1F63C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62D\" title=\"sob\" data-aliases=\"\" data-emoji=\"sob\" data-unicode-name=\"1F62D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-2728\" title=\"sparkles\" data-aliases=\"\" data-emoji=\"sparkles\" data-unicode-name=\"2728\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F496\" title=\"sparkling_heart\" data-aliases=\"\" data-emoji=\"sparkling_heart\" data-unicode-name=\"1F496\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64A\" title=\"speak_no_evil\" data-aliases=\"\" data-emoji=\"speak_no_evil\" data-unicode-name=\"1F64A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4AC\" title=\"speech_balloon\" data-aliases=\"\" data-emoji=\"speech_balloon\" data-unicode-name=\"1F4AC\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F31F\" title=\"star2\" data-aliases=\"\" data-emoji=\"star2\" data-unicode-name=\"1F31F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61B\" title=\"stuck_out_tongue\" data-aliases=\"\" data-emoji=\"stuck_out_tongue\" data-unicode-name=\"1F61B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61D\" title=\"stuck_out_tongue_closed_eyes\" data-aliases=\"\" data-emoji=\"stuck_out_tongue_closed_eyes\" data-unicode-name=\"1F61D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61C\" title=\"stuck_out_tongue_winking_eye\" data-aliases=\"\" data-emoji=\"stuck_out_tongue_winking_eye\" data-unicode-name=\"1F61C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60E\" title=\"sunglasses\" data-aliases=\"\" data-emoji=\"sunglasses\" data-unicode-name=\"1F60E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F613\" title=\"sweat\" data-aliases=\"\" data-emoji=\"sweat\" data-unicode-name=\"1F613\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A6\" title=\"sweat_drops\" data-aliases=\"\" data-emoji=\"sweat_drops\" data-unicode-name=\"1F4A6\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F605\" title=\"sweat_smile\" data-aliases=\"\" data-emoji=\"sweat_smile\" data-unicode-name=\"1F605\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4AD\" title=\"thought_balloon\" data-aliases=\"\" data-emoji=\"thought_balloon\" data-unicode-name=\"1F4AD\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44E\" title=\"thumbsdown\" data-aliases=\":-1:\" data-emoji=\"thumbsdown\" data-unicode-name=\"1F44E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44D\" title=\"thumbsup\" data-aliases=\":+1:\" data-emoji=\"thumbsup\" data-unicode-name=\"1F44D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62B\" title=\"tired_face\" data-aliases=\"\" data-emoji=\"tired_face\" data-unicode-name=\"1F62B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F445\" title=\"tongue\" data-aliases=\"\" data-emoji=\"tongue\" data-unicode-name=\"1F445\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F3A9\" title=\"tophat\" data-aliases=\"\" data-emoji=\"tophat\" data-unicode-name=\"1F3A9\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F624\" title=\"triumph\" data-aliases=\"\" data-emoji=\"triumph\" data-unicode-name=\"1F624\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F495\" title=\"two_hearts\" data-aliases=\"\" data-emoji=\"two_hearts\" data-unicode-name=\"1F495\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46C\" title=\"two_men_holding_hands\" data-aliases=\"\" data-emoji=\"two_men_holding_hands\" data-unicode-name=\"1F46C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46D\" title=\"two_women_holding_hands\" data-aliases=\"\" data-emoji=\"two_women_holding_hands\" data-unicode-name=\"1F46D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F612\" title=\"unamused\" data-aliases=\"\" data-emoji=\"unamused\" data-unicode-name=\"1F612\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-270C\" title=\"v\" data-aliases=\"\" data-emoji=\"v\" data-unicode-name=\"270C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F6B6\" title=\"walking\" data-aliases=\"\" data-emoji=\"walking\" data-unicode-name=\"1F6B6\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44B\" title=\"wave\" data-aliases=\"\" data-emoji=\"wave\" data-unicode-name=\"1F44B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F629\" title=\"weary\" data-aliases=\"\" data-emoji=\"weary\" data-unicode-name=\"1F629\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F609\" title=\"wink\" data-aliases=\"\" data-emoji=\"wink\" data-unicode-name=\"1F609\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F469\" title=\"woman\" data-aliases=\"\" data-emoji=\"woman\" data-unicode-name=\"1F469\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45A\" title=\"womans_clothes\" data-aliases=\"\" data-emoji=\"womans_clothes\" data-unicode-name=\"1F45A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F452\" title=\"womans_hat\" data-aliases=\"\" data-emoji=\"womans_hat\" data-unicode-name=\"1F452\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61F\" title=\"worried\" data-aliases=\"\" data-emoji=\"worried\" data-unicode-name=\"1F61F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F49B\" title=\"yellow_heart\" data-aliases=\"\" data-emoji=\"yellow_heart\" data-unicode-name=\"1F49B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60B\" title=\"yum\" data-aliases=\"\" data-emoji=\"yum\" data-unicode-name=\"1F60B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A4\" title=\"zzz\" data-aliases=\"\" data-emoji=\"zzz\" data-unicode-name=\"1F4A4\"></div>\n </button>\n </li>\n </ul>\n </div>\n</div>"; + +}).call(this); diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js new file mode 100644 index 00000000000..dc6231ebb38 --- /dev/null +++ b/spec/javascripts/issue_spec.js @@ -0,0 +1,121 @@ + +/*= require lib/utils/text_utility */ + + +/*= require issue */ + +(function() { + describe('Issue', function() { + return describe('task lists', function() { + fixture.preload('issues_show.html'); + beforeEach(function() { + fixture.load('issues_show.html'); + return this.issue = new Issue(); + }); + it('modifies the Markdown field', function() { + spyOn(jQuery, 'ajax').and.stub(); + $('input[type=checkbox]').attr('checked', true).trigger('change'); + return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + }); + return it('submits an ajax request on tasklist:changed', function() { + spyOn(jQuery, 'ajax').and.callFake(function(req) { + expect(req.type).toBe('PATCH'); + expect(req.url).toBe('/foo'); + return expect(req.data.issue.description).not.toBe(null); + }); + return $('.js-task-list-field').trigger('tasklist:changed'); + }); + }); + }); + + describe('reopen/close issue', function() { + fixture.preload('issues_show.html'); + beforeEach(function() { + fixture.load('issues_show.html'); + return this.issue = new Issue(); + }); + it('closes an issue', function() { + var $btnClose, $btnReopen; + spyOn(jQuery, 'ajax').and.callFake(function(req) { + expect(req.type).toBe('PUT'); + expect(req.url).toBe('http://gitlab.com/issues/6/close'); + return req.success({ + id: 34 + }); + }); + $btnClose = $('a.btn-close'); + $btnReopen = $('a.btn-reopen'); + expect($btnReopen).toBeHidden(); + expect($btnClose.text()).toBe('Close'); + expect(typeof $btnClose.prop('disabled')).toBe('undefined'); + $btnClose.trigger('click'); + expect($btnReopen).toBeVisible(); + expect($btnClose).toBeHidden(); + expect($('div.status-box-closed')).toBeVisible(); + return expect($('div.status-box-open')).toBeHidden(); + }); + it('fails to close an issue with success:false', function() { + var $btnClose, $btnReopen; + spyOn(jQuery, 'ajax').and.callFake(function(req) { + expect(req.type).toBe('PUT'); + expect(req.url).toBe('http://goesnowhere.nothing/whereami'); + return req.success({ + saved: false + }); + }); + $btnClose = $('a.btn-close'); + $btnReopen = $('a.btn-reopen'); + $btnClose.attr('href', 'http://goesnowhere.nothing/whereami'); + expect($btnReopen).toBeHidden(); + expect($btnClose.text()).toBe('Close'); + expect(typeof $btnClose.prop('disabled')).toBe('undefined'); + $btnClose.trigger('click'); + expect($btnReopen).toBeHidden(); + expect($btnClose).toBeVisible(); + expect($('div.status-box-closed')).toBeHidden(); + expect($('div.status-box-open')).toBeVisible(); + expect($('div.flash-alert')).toBeVisible(); + return expect($('div.flash-alert').text()).toBe('Unable to update this issue at this time.'); + }); + it('fails to closes an issue with HTTP error', function() { + var $btnClose, $btnReopen; + spyOn(jQuery, 'ajax').and.callFake(function(req) { + expect(req.type).toBe('PUT'); + expect(req.url).toBe('http://goesnowhere.nothing/whereami'); + return req.error(); + }); + $btnClose = $('a.btn-close'); + $btnReopen = $('a.btn-reopen'); + $btnClose.attr('href', 'http://goesnowhere.nothing/whereami'); + expect($btnReopen).toBeHidden(); + expect($btnClose.text()).toBe('Close'); + expect(typeof $btnClose.prop('disabled')).toBe('undefined'); + $btnClose.trigger('click'); + expect($btnReopen).toBeHidden(); + expect($btnClose).toBeVisible(); + expect($('div.status-box-closed')).toBeHidden(); + expect($('div.status-box-open')).toBeVisible(); + expect($('div.flash-alert')).toBeVisible(); + return expect($('div.flash-alert').text()).toBe('Unable to update this issue at this time.'); + }); + return it('reopens an issue', function() { + var $btnClose, $btnReopen; + spyOn(jQuery, 'ajax').and.callFake(function(req) { + expect(req.type).toBe('PUT'); + expect(req.url).toBe('http://gitlab.com/issues/6/reopen'); + return req.success({ + id: 34 + }); + }); + $btnClose = $('a.btn-close'); + $btnReopen = $('a.btn-reopen'); + expect($btnReopen.text()).toBe('Reopen'); + $btnReopen.trigger('click'); + expect($btnReopen).toBeHidden(); + expect($btnClose).toBeVisible(); + expect($('div.status-box-open')).toBeVisible(); + return expect($('div.status-box-closed')).toBeHidden(); + }); + }); + +}).call(this); diff --git a/spec/javascripts/issue_spec.js.coffee b/spec/javascripts/issue_spec.js.coffee deleted file mode 100644 index d84d80f266b..00000000000 --- a/spec/javascripts/issue_spec.js.coffee +++ /dev/null @@ -1,109 +0,0 @@ -#= require lib/utils/text_utility -#= require issue - -describe 'Issue', -> - describe 'task lists', -> - fixture.preload('issues_show.html') - - beforeEach -> - fixture.load('issues_show.html') - @issue = new Issue() - - it 'modifies the Markdown field', -> - 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', -> - spyOn(jQuery, 'ajax').and.callFake (req) -> - expect(req.type).toBe('PATCH') - expect(req.url).toBe('/foo') - expect(req.data.issue.description).not.toBe(null) - - $('.js-task-list-field').trigger('tasklist:changed') -describe 'reopen/close issue', -> - fixture.preload('issues_show.html') - beforeEach -> - fixture.load('issues_show.html') - @issue = new Issue() - it 'closes an issue', -> - spyOn(jQuery, 'ajax').and.callFake (req) -> - expect(req.type).toBe('PUT') - expect(req.url).toBe('http://gitlab.com/issues/6/close') - req.success id: 34 - - $btnClose = $('a.btn-close') - $btnReopen = $('a.btn-reopen') - expect($btnReopen).toBeHidden() - expect($btnClose.text()).toBe('Close') - expect(typeof $btnClose.prop('disabled')).toBe('undefined') - - $btnClose.trigger('click') - - expect($btnReopen).toBeVisible() - expect($btnClose).toBeHidden() - expect($('div.status-box-closed')).toBeVisible() - expect($('div.status-box-open')).toBeHidden() - - it 'fails to close an issue with success:false', -> - - spyOn(jQuery, 'ajax').and.callFake (req) -> - expect(req.type).toBe('PUT') - expect(req.url).toBe('http://goesnowhere.nothing/whereami') - req.success saved: false - - $btnClose = $('a.btn-close') - $btnReopen = $('a.btn-reopen') - $btnClose.attr('href','http://goesnowhere.nothing/whereami') - expect($btnReopen).toBeHidden() - expect($btnClose.text()).toBe('Close') - expect(typeof $btnClose.prop('disabled')).toBe('undefined') - - $btnClose.trigger('click') - - expect($btnReopen).toBeHidden() - expect($btnClose).toBeVisible() - expect($('div.status-box-closed')).toBeHidden() - expect($('div.status-box-open')).toBeVisible() - expect($('div.flash-alert')).toBeVisible() - expect($('div.flash-alert').text()).toBe('Unable to update this issue at this time.') - - it 'fails to closes an issue with HTTP error', -> - - spyOn(jQuery, 'ajax').and.callFake (req) -> - expect(req.type).toBe('PUT') - expect(req.url).toBe('http://goesnowhere.nothing/whereami') - req.error() - - $btnClose = $('a.btn-close') - $btnReopen = $('a.btn-reopen') - $btnClose.attr('href','http://goesnowhere.nothing/whereami') - expect($btnReopen).toBeHidden() - expect($btnClose.text()).toBe('Close') - expect(typeof $btnClose.prop('disabled')).toBe('undefined') - - $btnClose.trigger('click') - - expect($btnReopen).toBeHidden() - expect($btnClose).toBeVisible() - expect($('div.status-box-closed')).toBeHidden() - expect($('div.status-box-open')).toBeVisible() - expect($('div.flash-alert')).toBeVisible() - expect($('div.flash-alert').text()).toBe('Unable to update this issue at this time.') - - it 'reopens an issue', -> - spyOn(jQuery, 'ajax').and.callFake (req) -> - expect(req.type).toBe('PUT') - expect(req.url).toBe('http://gitlab.com/issues/6/reopen') - req.success id: 34 - - $btnClose = $('a.btn-close') - $btnReopen = $('a.btn-reopen') - expect($btnReopen.text()).toBe('Reopen') - - $btnReopen.trigger('click') - - expect($btnReopen).toBeHidden() - expect($btnClose).toBeVisible() - expect($('div.status-box-open')).toBeVisible() - expect($('div.status-box-closed')).toBeHidden() diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js new file mode 100644 index 00000000000..e2789571607 --- /dev/null +++ b/spec/javascripts/line_highlighter_spec.js @@ -0,0 +1,229 @@ + +/*= require line_highlighter */ + +(function() { + describe('LineHighlighter', function() { + var clickLine; + fixture.preload('line_highlighter.html'); + clickLine = function(number, eventData) { + var e; + if (eventData == null) { + eventData = {}; + } + if ($.isEmptyObject(eventData)) { + return $("#L" + number).mousedown().click(); + } else { + e = $.Event('mousedown', eventData); + return $("#L" + number).trigger(e).click(); + } + }; + beforeEach(function() { + fixture.load('line_highlighter.html'); + this["class"] = new LineHighlighter(); + this.css = this["class"].highlightClass; + return this.spies = { + __setLocationHash__: spyOn(this["class"], '__setLocationHash__').and.callFake(function() {}) + }; + }); + describe('behavior', function() { + it('highlights one line given in the URL hash', function() { + new LineHighlighter('#L13'); + return expect($('#LC13')).toHaveClass(this.css); + }); + it('highlights a range of lines given in the URL hash', function() { + var i, line, results; + new LineHighlighter('#L5-25'); + expect($("." + this.css).length).toBe(21); + results = []; + for (line = i = 5; i <= 25; line = ++i) { + results.push(expect($("#LC" + line)).toHaveClass(this.css)); + } + return results; + }); + it('scrolls to the first highlighted line on initial load', function() { + var spy; + spy = spyOn($, 'scrollTo'); + new LineHighlighter('#L5-25'); + return expect(spy).toHaveBeenCalledWith('#L5', jasmine.anything()); + }); + it('discards click events', function() { + var spy; + spy = spyOnEvent('a[data-line-number]', 'click'); + clickLine(13); + return expect(spy).toHaveBeenPrevented(); + }); + return it('handles garbage input from the hash', function() { + var func; + func = function() { + return new LineHighlighter('#blob-content-holder'); + }; + return expect(func).not.toThrow(); + }); + }); + describe('#clickHandler', function() { + it('discards the mousedown event', function() { + var spy; + spy = spyOnEvent('a[data-line-number]', 'mousedown'); + clickLine(13); + return expect(spy).toHaveBeenPrevented(); + }); + it('handles clicking on a child icon element', function() { + var spy; + spy = spyOn(this["class"], 'setHash').and.callThrough(); + $('#L13 i').mousedown().click(); + expect(spy).toHaveBeenCalledWith(13); + return expect($('#LC13')).toHaveClass(this.css); + }); + describe('without shiftKey', function() { + it('highlights one line when clicked', function() { + clickLine(13); + return expect($('#LC13')).toHaveClass(this.css); + }); + it('unhighlights previously highlighted lines', function() { + clickLine(13); + clickLine(20); + expect($('#LC13')).not.toHaveClass(this.css); + return expect($('#LC20')).toHaveClass(this.css); + }); + return it('sets the hash', function() { + var spy; + spy = spyOn(this["class"], 'setHash').and.callThrough(); + clickLine(13); + return expect(spy).toHaveBeenCalledWith(13); + }); + }); + return describe('with shiftKey', function() { + it('sets the hash', function() { + var spy; + spy = spyOn(this["class"], 'setHash').and.callThrough(); + clickLine(13); + clickLine(20, { + shiftKey: true + }); + expect(spy).toHaveBeenCalledWith(13); + return expect(spy).toHaveBeenCalledWith(13, 20); + }); + describe('without existing highlight', function() { + it('highlights the clicked line', function() { + clickLine(13, { + shiftKey: true + }); + expect($('#LC13')).toHaveClass(this.css); + return expect($("." + this.css).length).toBe(1); + }); + return it('sets the hash', function() { + var spy; + spy = spyOn(this["class"], 'setHash'); + clickLine(13, { + shiftKey: true + }); + return expect(spy).toHaveBeenCalledWith(13); + }); + }); + describe('with existing single-line highlight', function() { + it('uses existing line as last line when target is lesser', function() { + var i, line, results; + clickLine(20); + clickLine(15, { + shiftKey: true + }); + expect($("." + this.css).length).toBe(6); + results = []; + for (line = i = 15; i <= 20; line = ++i) { + results.push(expect($("#LC" + line)).toHaveClass(this.css)); + } + return results; + }); + return it('uses existing line as first line when target is greater', function() { + var i, line, results; + clickLine(5); + clickLine(10, { + shiftKey: true + }); + expect($("." + this.css).length).toBe(6); + results = []; + for (line = i = 5; i <= 10; line = ++i) { + results.push(expect($("#LC" + line)).toHaveClass(this.css)); + } + return results; + }); + }); + return describe('with existing multi-line highlight', function() { + beforeEach(function() { + clickLine(10, { + shiftKey: true + }); + return clickLine(13, { + shiftKey: true + }); + }); + it('uses target as first line when it is less than existing first line', function() { + var i, line, results; + clickLine(5, { + shiftKey: true + }); + expect($("." + this.css).length).toBe(6); + results = []; + for (line = i = 5; i <= 10; line = ++i) { + results.push(expect($("#LC" + line)).toHaveClass(this.css)); + } + return results; + }); + return it('uses target as last line when it is greater than existing first line', function() { + var i, line, results; + clickLine(15, { + shiftKey: true + }); + expect($("." + this.css).length).toBe(6); + results = []; + for (line = i = 10; i <= 15; line = ++i) { + results.push(expect($("#LC" + line)).toHaveClass(this.css)); + } + return results; + }); + }); + }); + }); + describe('#hashToRange', function() { + beforeEach(function() { + return this.subject = this["class"].hashToRange; + }); + it('extracts a single line number from the hash', function() { + return expect(this.subject('#L5')).toEqual([5, null]); + }); + it('extracts a range of line numbers from the hash', function() { + return expect(this.subject('#L5-15')).toEqual([5, 15]); + }); + return it('returns [null, null] when the hash is not a line number', function() { + return expect(this.subject('#foo')).toEqual([null, null]); + }); + }); + describe('#highlightLine', function() { + beforeEach(function() { + return this.subject = this["class"].highlightLine; + }); + it('highlights the specified line', function() { + this.subject(13); + return expect($('#LC13')).toHaveClass(this.css); + }); + return it('accepts a String-based number', function() { + this.subject('13'); + return expect($('#LC13')).toHaveClass(this.css); + }); + }); + return describe('#setHash', function() { + beforeEach(function() { + return this.subject = this["class"].setHash; + }); + it('sets the location hash for a single line', function() { + this.subject(5); + return expect(this.spies.__setLocationHash__).toHaveBeenCalledWith('#L5'); + }); + return it('sets the location hash for a range', function() { + this.subject(5, 15); + return expect(this.spies.__setLocationHash__).toHaveBeenCalledWith('#L5-15'); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/line_highlighter_spec.js.coffee b/spec/javascripts/line_highlighter_spec.js.coffee deleted file mode 100644 index a073f21e7bc..00000000000 --- a/spec/javascripts/line_highlighter_spec.js.coffee +++ /dev/null @@ -1,158 +0,0 @@ -#= require line_highlighter - -describe 'LineHighlighter', -> - fixture.preload('line_highlighter.html') - - clickLine = (number, eventData = {}) -> - if $.isEmptyObject(eventData) - $("#L#{number}").mousedown().click() - else - e = $.Event 'mousedown', eventData - $("#L#{number}").trigger(e).click() - - beforeEach -> - fixture.load('line_highlighter.html') - @class = new LineHighlighter() - @css = @class.highlightClass - @spies = { - __setLocationHash__: spyOn(@class, '__setLocationHash__').and.callFake -> - } - - describe 'behavior', -> - it 'highlights one line given in the URL hash', -> - new LineHighlighter('#L13') - expect($('#LC13')).toHaveClass(@css) - - it 'highlights a range of lines given in the URL hash', -> - new LineHighlighter('#L5-25') - expect($(".#{@css}").length).toBe(21) - expect($("#LC#{line}")).toHaveClass(@css) for line in [5..25] - - it 'scrolls to the first highlighted line on initial load', -> - spy = spyOn($, 'scrollTo') - new LineHighlighter('#L5-25') - expect(spy).toHaveBeenCalledWith('#L5', jasmine.anything()) - - it 'discards click events', -> - spy = spyOnEvent('a[data-line-number]', 'click') - clickLine(13) - expect(spy).toHaveBeenPrevented() - - it 'handles garbage input from the hash', -> - func = -> new LineHighlighter('#blob-content-holder') - expect(func).not.toThrow() - - describe '#clickHandler', -> - it 'discards the mousedown event', -> - spy = spyOnEvent('a[data-line-number]', 'mousedown') - clickLine(13) - expect(spy).toHaveBeenPrevented() - - it 'handles clicking on a child icon element', -> - spy = spyOn(@class, 'setHash').and.callThrough() - - $('#L13 i').mousedown().click() - - expect(spy).toHaveBeenCalledWith(13) - expect($('#LC13')).toHaveClass(@css) - - describe 'without shiftKey', -> - it 'highlights one line when clicked', -> - clickLine(13) - expect($('#LC13')).toHaveClass(@css) - - it 'unhighlights previously highlighted lines', -> - clickLine(13) - clickLine(20) - - expect($('#LC13')).not.toHaveClass(@css) - expect($('#LC20')).toHaveClass(@css) - - it 'sets the hash', -> - spy = spyOn(@class, 'setHash').and.callThrough() - clickLine(13) - expect(spy).toHaveBeenCalledWith(13) - - describe 'with shiftKey', -> - it 'sets the hash', -> - spy = spyOn(@class, 'setHash').and.callThrough() - clickLine(13) - clickLine(20, shiftKey: true) - expect(spy).toHaveBeenCalledWith(13) - expect(spy).toHaveBeenCalledWith(13, 20) - - describe 'without existing highlight', -> - it 'highlights the clicked line', -> - clickLine(13, shiftKey: true) - expect($('#LC13')).toHaveClass(@css) - expect($(".#{@css}").length).toBe(1) - - it 'sets the hash', -> - spy = spyOn(@class, 'setHash') - clickLine(13, shiftKey: true) - expect(spy).toHaveBeenCalledWith(13) - - describe 'with existing single-line highlight', -> - it 'uses existing line as last line when target is lesser', -> - clickLine(20) - clickLine(15, shiftKey: true) - expect($(".#{@css}").length).toBe(6) - expect($("#LC#{line}")).toHaveClass(@css) for line in [15..20] - - it 'uses existing line as first line when target is greater', -> - clickLine(5) - clickLine(10, shiftKey: true) - expect($(".#{@css}").length).toBe(6) - expect($("#LC#{line}")).toHaveClass(@css) for line in [5..10] - - describe 'with existing multi-line highlight', -> - beforeEach -> - clickLine(10, shiftKey: true) - clickLine(13, shiftKey: true) - - it 'uses target as first line when it is less than existing first line', -> - clickLine(5, shiftKey: true) - expect($(".#{@css}").length).toBe(6) - expect($("#LC#{line}")).toHaveClass(@css) for line in [5..10] - - it 'uses target as last line when it is greater than existing first line', -> - clickLine(15, shiftKey: true) - expect($(".#{@css}").length).toBe(6) - expect($("#LC#{line}")).toHaveClass(@css) for line in [10..15] - - describe '#hashToRange', -> - beforeEach -> - @subject = @class.hashToRange - - it 'extracts a single line number from the hash', -> - expect(@subject('#L5')).toEqual([5, null]) - - it 'extracts a range of line numbers from the hash', -> - expect(@subject('#L5-15')).toEqual([5, 15]) - - it 'returns [null, null] when the hash is not a line number', -> - expect(@subject('#foo')).toEqual([null, null]) - - describe '#highlightLine', -> - beforeEach -> - @subject = @class.highlightLine - - it 'highlights the specified line', -> - @subject(13) - expect($('#LC13')).toHaveClass(@css) - - it 'accepts a String-based number', -> - @subject('13') - expect($('#LC13')).toHaveClass(@css) - - describe '#setHash', -> - beforeEach -> - @subject = @class.setHash - - it 'sets the location hash for a single line', -> - @subject(5) - expect(@spies.__setLocationHash__).toHaveBeenCalledWith('#L5') - - it 'sets the location hash for a range', -> - @subject(5, 15) - expect(@spies.__setLocationHash__).toHaveBeenCalledWith('#L5-15') diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js new file mode 100644 index 00000000000..61830d267a9 --- /dev/null +++ b/spec/javascripts/merge_request_spec.js @@ -0,0 +1,28 @@ + +/*= require merge_request */ + +(function() { + describe('MergeRequest', function() { + return describe('task lists', function() { + fixture.preload('merge_requests_show.html'); + beforeEach(function() { + fixture.load('merge_requests_show.html'); + return this.merge = new MergeRequest(); + }); + it('modifies the Markdown field', function() { + spyOn(jQuery, 'ajax').and.stub(); + $('input[type=checkbox]').attr('checked', true).trigger('change'); + return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + }); + return it('submits an ajax request on tasklist:changed', function() { + spyOn(jQuery, 'ajax').and.callFake(function(req) { + expect(req.type).toBe('PATCH'); + expect(req.url).toBe('/foo'); + return expect(req.data.merge_request.description).not.toBe(null); + }); + return $('.js-task-list-field').trigger('tasklist:changed'); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/merge_request_spec.js.coffee b/spec/javascripts/merge_request_spec.js.coffee deleted file mode 100644 index 3cb67d51c85..00000000000 --- a/spec/javascripts/merge_request_spec.js.coffee +++ /dev/null @@ -1,23 +0,0 @@ -#= require merge_request - -describe 'MergeRequest', -> - describe 'task lists', -> - fixture.preload('merge_requests_show.html') - - beforeEach -> - fixture.load('merge_requests_show.html') - @merge = new MergeRequest() - - it 'modifies the Markdown field', -> - 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', -> - spyOn(jQuery, 'ajax').and.callFake (req) -> - expect(req.type).toBe('PATCH') - expect(req.url).toBe('/foo') - expect(req.data.merge_request.description).not.toBe(null) - - $('.js-task-list-field').trigger('tasklist:changed') diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js new file mode 100644 index 00000000000..395032a7416 --- /dev/null +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -0,0 +1,106 @@ + +/*= require merge_request_tabs */ + +(function() { + describe('MergeRequestTabs', function() { + var stubLocation; + stubLocation = function(stubs) { + var defaults; + defaults = { + pathname: '', + search: '', + hash: '' + }; + return $.extend(defaults, stubs); + }; + fixture.preload('merge_request_tabs.html'); + beforeEach(function() { + this["class"] = new MergeRequestTabs(); + return this.spies = { + ajax: spyOn($, 'ajax').and.callFake(function() {}), + history: spyOn(history, 'replaceState').and.callFake(function() {}) + }; + }); + describe('#activateTab', function() { + beforeEach(function() { + fixture.load('merge_request_tabs.html'); + return this.subject = this["class"].activateTab; + }); + it('shows the first tab when action is show', function() { + this.subject('show'); + return expect($('#notes')).toHaveClass('active'); + }); + it('shows the notes tab when action is notes', function() { + this.subject('notes'); + return expect($('#notes')).toHaveClass('active'); + }); + it('shows the commits tab when action is commits', function() { + this.subject('commits'); + return expect($('#commits')).toHaveClass('active'); + }); + return it('shows the diffs tab when action is diffs', function() { + this.subject('diffs'); + return expect($('#diffs')).toHaveClass('active'); + }); + }); + return describe('#setCurrentAction', function() { + beforeEach(function() { + return this.subject = this["class"].setCurrentAction; + }); + it('changes from commits', function() { + this["class"]._location = stubLocation({ + pathname: '/foo/bar/merge_requests/1/commits' + }); + expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1'); + return expect(this.subject('diffs')).toBe('/foo/bar/merge_requests/1/diffs'); + }); + it('changes from diffs', function() { + this["class"]._location = stubLocation({ + pathname: '/foo/bar/merge_requests/1/diffs' + }); + expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1'); + return expect(this.subject('commits')).toBe('/foo/bar/merge_requests/1/commits'); + }); + it('changes from diffs.html', function() { + this["class"]._location = stubLocation({ + pathname: '/foo/bar/merge_requests/1/diffs.html' + }); + expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1'); + return expect(this.subject('commits')).toBe('/foo/bar/merge_requests/1/commits'); + }); + it('changes from notes', function() { + this["class"]._location = stubLocation({ + pathname: '/foo/bar/merge_requests/1' + }); + expect(this.subject('diffs')).toBe('/foo/bar/merge_requests/1/diffs'); + return expect(this.subject('commits')).toBe('/foo/bar/merge_requests/1/commits'); + }); + it('includes search parameters and hash string', function() { + this["class"]._location = stubLocation({ + pathname: '/foo/bar/merge_requests/1/diffs', + search: '?view=parallel', + hash: '#L15-35' + }); + return expect(this.subject('show')).toBe('/foo/bar/merge_requests/1?view=parallel#L15-35'); + }); + it('replaces the current history state', function() { + var new_state; + this["class"]._location = stubLocation({ + pathname: '/foo/bar/merge_requests/1' + }); + new_state = this.subject('commits'); + return expect(this.spies.history).toHaveBeenCalledWith({ + turbolinks: true, + url: new_state + }, document.title, new_state); + }); + return it('treats "show" like "notes"', function() { + this["class"]._location = stubLocation({ + pathname: '/foo/bar/merge_requests/1/commits' + }); + return expect(this.subject('show')).toBe('/foo/bar/merge_requests/1'); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/merge_request_tabs_spec.js.coffee b/spec/javascripts/merge_request_tabs_spec.js.coffee deleted file mode 100644 index a0cfba455ea..00000000000 --- a/spec/javascripts/merge_request_tabs_spec.js.coffee +++ /dev/null @@ -1,88 +0,0 @@ -#= require merge_request_tabs - -describe 'MergeRequestTabs', -> - stubLocation = (stubs) -> - defaults = {pathname: '', search: '', hash: ''} - $.extend(defaults, stubs) - - fixture.preload('merge_request_tabs.html') - - beforeEach -> - @class = new MergeRequestTabs() - @spies = { - ajax: spyOn($, 'ajax').and.callFake -> - history: spyOn(history, 'replaceState').and.callFake -> - } - - describe '#activateTab', -> - beforeEach -> - fixture.load('merge_request_tabs.html') - @subject = @class.activateTab - - it 'shows the first tab when action is show', -> - @subject('show') - expect($('#notes')).toHaveClass('active') - - it 'shows the notes tab when action is notes', -> - @subject('notes') - expect($('#notes')).toHaveClass('active') - - it 'shows the commits tab when action is commits', -> - @subject('commits') - expect($('#commits')).toHaveClass('active') - - it 'shows the diffs tab when action is diffs', -> - @subject('diffs') - expect($('#diffs')).toHaveClass('active') - - describe '#setCurrentAction', -> - beforeEach -> - @subject = @class.setCurrentAction - - it 'changes from commits', -> - @class._location = stubLocation(pathname: '/foo/bar/merge_requests/1/commits') - - expect(@subject('notes')).toBe('/foo/bar/merge_requests/1') - expect(@subject('diffs')).toBe('/foo/bar/merge_requests/1/diffs') - - it 'changes from diffs', -> - @class._location = stubLocation(pathname: '/foo/bar/merge_requests/1/diffs') - - expect(@subject('notes')).toBe('/foo/bar/merge_requests/1') - expect(@subject('commits')).toBe('/foo/bar/merge_requests/1/commits') - - it 'changes from diffs.html', -> - @class._location = stubLocation(pathname: '/foo/bar/merge_requests/1/diffs.html') - - expect(@subject('notes')).toBe('/foo/bar/merge_requests/1') - expect(@subject('commits')).toBe('/foo/bar/merge_requests/1/commits') - - it 'changes from notes', -> - @class._location = stubLocation(pathname: '/foo/bar/merge_requests/1') - - expect(@subject('diffs')).toBe('/foo/bar/merge_requests/1/diffs') - expect(@subject('commits')).toBe('/foo/bar/merge_requests/1/commits') - - it 'includes search parameters and hash string', -> - @class._location = stubLocation({ - pathname: '/foo/bar/merge_requests/1/diffs' - search: '?view=parallel' - hash: '#L15-35' - }) - - expect(@subject('show')).toBe('/foo/bar/merge_requests/1?view=parallel#L15-35') - - it 'replaces the current history state', -> - @class._location = stubLocation(pathname: '/foo/bar/merge_requests/1') - new_state = @subject('commits') - - expect(@spies.history).toHaveBeenCalledWith( - {turbolinks: true, url: new_state}, - document.title, - new_state - ) - - it 'treats "show" like "notes"', -> - @class._location = stubLocation(pathname: '/foo/bar/merge_requests/1/commits') - - expect(@subject('show')).toBe('/foo/bar/merge_requests/1') diff --git a/spec/javascripts/merge_request_widget_spec.js b/spec/javascripts/merge_request_widget_spec.js new file mode 100644 index 00000000000..17b32914ec3 --- /dev/null +++ b/spec/javascripts/merge_request_widget_spec.js @@ -0,0 +1,74 @@ + +/*= require merge_request_widget */ + +(function() { + describe('MergeRequestWidget', function() { + beforeEach(function() { + window.notifyPermissions = function() {}; + window.notify = function() {}; + this.opts = { + ci_status_url: "http://sampledomain.local/ci/getstatus", + ci_status: "", + ci_message: { + normal: "Build {{status}} for \"{{title}}\"", + preparing: "{{status}} build for \"{{title}}\"" + }, + ci_title: { + preparing: "{{status}} build", + normal: "Build {{status}}" + }, + gitlab_icon: "gitlab_logo.png", + builds_path: "http://sampledomain.local/sampleBuildsPath" + }; + this["class"] = new MergeRequestWidget(this.opts); + return this.ciStatusData = { + "title": "Sample MR title", + "sha": "12a34bc5", + "status": "success", + "coverage": 98 + }; + }); + return describe('getCIStatus', function() { + beforeEach(function() { + return spyOn(jQuery, 'getJSON').and.callFake((function(_this) { + return function(req, cb) { + return cb(_this.ciStatusData); + }; + })(this)); + }); + it('should call showCIStatus even if a notification should not be displayed', function() { + var spy; + spy = spyOn(this["class"], 'showCIStatus').and.stub(); + this["class"].getCIStatus(false); + return expect(spy).toHaveBeenCalledWith(this.ciStatusData.status); + }); + it('should call showCIStatus when a notification should be displayed', function() { + var spy; + spy = spyOn(this["class"], 'showCIStatus').and.stub(); + this["class"].getCIStatus(true); + return expect(spy).toHaveBeenCalledWith(this.ciStatusData.status); + }); + it('should call showCICoverage when the coverage rate is set', function() { + var spy; + spy = spyOn(this["class"], 'showCICoverage').and.stub(); + this["class"].getCIStatus(false); + return expect(spy).toHaveBeenCalledWith(this.ciStatusData.coverage); + }); + it('should not call showCICoverage when the coverage rate is not set', function() { + var spy; + this.ciStatusData.coverage = null; + spy = spyOn(this["class"], 'showCICoverage').and.stub(); + this["class"].getCIStatus(false); + return expect(spy).not.toHaveBeenCalled(); + }); + return it('should not display a notification on the first check after the widget has been created', function() { + var spy; + spy = spyOn(window, 'notify'); + this["class"] = new MergeRequestWidget(this.opts); + this["class"].getCIStatus(true); + return expect(spy).not.toHaveBeenCalled(); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/merge_request_widget_spec.js.coffee b/spec/javascripts/merge_request_widget_spec.js.coffee deleted file mode 100644 index 92b7eeb1116..00000000000 --- a/spec/javascripts/merge_request_widget_spec.js.coffee +++ /dev/null @@ -1,55 +0,0 @@ -#= require merge_request_widget - -describe 'MergeRequestWidget', -> - - beforeEach -> - window.notifyPermissions = () -> - window.notify = () -> - @opts = { - ci_status_url:"http://sampledomain.local/ci/getstatus", - ci_status:"", - ci_message: { - normal: "Build {{status}} for \"{{title}}\"", - preparing: "{{status}} build for \"{{title}}\"" - }, - ci_title: { - preparing: "{{status}} build", - normal: "Build {{status}}" - }, - gitlab_icon:"gitlab_logo.png", - builds_path:"http://sampledomain.local/sampleBuildsPath" - } - @class = new MergeRequestWidget(@opts) - @ciStatusData = {"title":"Sample MR title","sha":"12a34bc5","status":"success","coverage":98} - - describe 'getCIStatus', -> - beforeEach -> - spyOn(jQuery, 'getJSON').and.callFake (req, cb) => - cb(@ciStatusData) - - it 'should call showCIStatus even if a notification should not be displayed', -> - spy = spyOn(@class, 'showCIStatus').and.stub() - @class.getCIStatus(false) - expect(spy).toHaveBeenCalledWith(@ciStatusData.status) - - it 'should call showCIStatus when a notification should be displayed', -> - spy = spyOn(@class, 'showCIStatus').and.stub() - @class.getCIStatus(true) - expect(spy).toHaveBeenCalledWith(@ciStatusData.status) - - it 'should call showCICoverage when the coverage rate is set', -> - spy = spyOn(@class, 'showCICoverage').and.stub() - @class.getCIStatus(false) - expect(spy).toHaveBeenCalledWith(@ciStatusData.coverage) - - it 'should not call showCICoverage when the coverage rate is not set', -> - @ciStatusData.coverage = null - spy = spyOn(@class, 'showCICoverage').and.stub() - @class.getCIStatus(false) - expect(spy).not.toHaveBeenCalled() - - it 'should not display a notification on the first check after the widget has been created', -> - spy = spyOn(window, 'notify') - @class = new MergeRequestWidget(@opts) - @class.getCIStatus(true) - expect(spy).not.toHaveBeenCalled() diff --git a/spec/javascripts/new_branch_spec.js b/spec/javascripts/new_branch_spec.js new file mode 100644 index 00000000000..25d3f5b6c04 --- /dev/null +++ b/spec/javascripts/new_branch_spec.js @@ -0,0 +1,170 @@ + +/*= require jquery-ui/autocomplete */ + + +/*= require new_branch_form */ + +(function() { + describe('Branch', function() { + return describe('create a new branch', function() { + var expectToHaveError, fillNameWith; + fixture.preload('new_branch.html'); + fillNameWith = function(value) { + return $('.js-branch-name').val(value).trigger('blur'); + }; + expectToHaveError = function(error) { + return expect($('.js-branch-name-error span').text()).toEqual(error); + }; + beforeEach(function() { + fixture.load('new_branch.html'); + $('form').on('submit', function(e) { + return e.preventDefault(); + }); + return this.form = new NewBranchForm($('.js-create-branch-form'), []); + }); + it("can't start with a dot", function() { + fillNameWith('.foo'); + return expectToHaveError("can't start with '.'"); + }); + it("can't start with a slash", function() { + fillNameWith('/foo'); + return expectToHaveError("can't start with '/'"); + }); + it("can't have two consecutive dots", function() { + fillNameWith('foo..bar'); + return expectToHaveError("can't contain '..'"); + }); + it("can't have spaces anywhere", function() { + fillNameWith(' foo'); + expectToHaveError("can't contain spaces"); + fillNameWith('foo bar'); + expectToHaveError("can't contain spaces"); + fillNameWith('foo '); + return expectToHaveError("can't contain spaces"); + }); + it("can't have ~ anywhere", function() { + fillNameWith('~foo'); + expectToHaveError("can't contain '~'"); + fillNameWith('foo~bar'); + expectToHaveError("can't contain '~'"); + fillNameWith('foo~'); + return expectToHaveError("can't contain '~'"); + }); + it("can't have tilde anwhere", function() { + fillNameWith('~foo'); + expectToHaveError("can't contain '~'"); + fillNameWith('foo~bar'); + expectToHaveError("can't contain '~'"); + fillNameWith('foo~'); + return expectToHaveError("can't contain '~'"); + }); + it("can't have caret anywhere", function() { + fillNameWith('^foo'); + expectToHaveError("can't contain '^'"); + fillNameWith('foo^bar'); + expectToHaveError("can't contain '^'"); + fillNameWith('foo^'); + return expectToHaveError("can't contain '^'"); + }); + it("can't have : anywhere", function() { + fillNameWith(':foo'); + expectToHaveError("can't contain ':'"); + fillNameWith('foo:bar'); + expectToHaveError("can't contain ':'"); + fillNameWith(':foo'); + return expectToHaveError("can't contain ':'"); + }); + it("can't have question mark anywhere", function() { + fillNameWith('?foo'); + expectToHaveError("can't contain '?'"); + fillNameWith('foo?bar'); + expectToHaveError("can't contain '?'"); + fillNameWith('foo?'); + return expectToHaveError("can't contain '?'"); + }); + it("can't have asterisk anywhere", function() { + fillNameWith('*foo'); + expectToHaveError("can't contain '*'"); + fillNameWith('foo*bar'); + expectToHaveError("can't contain '*'"); + fillNameWith('foo*'); + return expectToHaveError("can't contain '*'"); + }); + it("can't have open bracket anywhere", function() { + fillNameWith('[foo'); + expectToHaveError("can't contain '['"); + fillNameWith('foo[bar'); + expectToHaveError("can't contain '['"); + fillNameWith('foo['); + return expectToHaveError("can't contain '['"); + }); + it("can't have a backslash anywhere", function() { + fillNameWith('\\foo'); + expectToHaveError("can't contain '\\'"); + fillNameWith('foo\\bar'); + expectToHaveError("can't contain '\\'"); + fillNameWith('foo\\'); + return expectToHaveError("can't contain '\\'"); + }); + it("can't contain a sequence @{ anywhere", function() { + fillNameWith('@{foo'); + expectToHaveError("can't contain '@{'"); + fillNameWith('foo@{bar'); + expectToHaveError("can't contain '@{'"); + fillNameWith('foo@{'); + return expectToHaveError("can't contain '@{'"); + }); + it("can't have consecutive slashes", function() { + fillNameWith('foo//bar'); + return expectToHaveError("can't contain consecutive slashes"); + }); + it("can't end with a slash", function() { + fillNameWith('foo/'); + return expectToHaveError("can't end in '/'"); + }); + it("can't end with a dot", function() { + fillNameWith('foo.'); + return expectToHaveError("can't end in '.'"); + }); + it("can't end with .lock", function() { + fillNameWith('foo.lock'); + return expectToHaveError("can't end in '.lock'"); + }); + it("can't be the single character @", function() { + fillNameWith('@'); + return expectToHaveError("can't be '@'"); + }); + it("concatenates all error messages", function() { + fillNameWith('/foo bar?~.'); + return expectToHaveError("can't start with '/', can't contain spaces, '?', '~', can't end in '.'"); + }); + it("doesn't duplicate error messages", function() { + fillNameWith('?foo?bar?zoo?'); + return expectToHaveError("can't contain '?'"); + }); + it("removes the error message when is a valid name", function() { + fillNameWith('foo?bar'); + expect($('.js-branch-name-error span').length).toEqual(1); + fillNameWith('foobar'); + return expect($('.js-branch-name-error span').length).toEqual(0); + }); + it("can have dashes anywhere", function() { + fillNameWith('-foo-bar-zoo-'); + return expect($('.js-branch-name-error span').length).toEqual(0); + }); + it("can have underscores anywhere", function() { + fillNameWith('_foo_bar_zoo_'); + return expect($('.js-branch-name-error span').length).toEqual(0); + }); + it("can have numbers anywhere", function() { + fillNameWith('1foo2bar3zoo4'); + return expect($('.js-branch-name-error span').length).toEqual(0); + }); + return it("can be only letters", function() { + fillNameWith('foo'); + return expect($('.js-branch-name-error span').length).toEqual(0); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/new_branch_spec.js.coffee b/spec/javascripts/new_branch_spec.js.coffee deleted file mode 100644 index ce773793817..00000000000 --- a/spec/javascripts/new_branch_spec.js.coffee +++ /dev/null @@ -1,160 +0,0 @@ -#= require jquery-ui/autocomplete -#= require new_branch_form - -describe 'Branch', -> - describe 'create a new branch', -> - fixture.preload('new_branch.html') - - fillNameWith = (value) -> - $('.js-branch-name').val(value).trigger('blur') - - expectToHaveError = (error) -> - expect($('.js-branch-name-error span').text()).toEqual(error) - - beforeEach -> - fixture.load('new_branch.html') - $('form').on 'submit', (e) -> e.preventDefault() - - @form = new NewBranchForm($('.js-create-branch-form'), []) - - it "can't start with a dot", -> - fillNameWith '.foo' - expectToHaveError "can't start with '.'" - - it "can't start with a slash", -> - fillNameWith '/foo' - expectToHaveError "can't start with '/'" - - it "can't have two consecutive dots", -> - fillNameWith 'foo..bar' - expectToHaveError "can't contain '..'" - - it "can't have spaces anywhere", -> - fillNameWith ' foo' - expectToHaveError "can't contain spaces" - fillNameWith 'foo bar' - expectToHaveError "can't contain spaces" - fillNameWith 'foo ' - expectToHaveError "can't contain spaces" - - it "can't have ~ anywhere", -> - fillNameWith '~foo' - expectToHaveError "can't contain '~'" - fillNameWith 'foo~bar' - expectToHaveError "can't contain '~'" - fillNameWith 'foo~' - expectToHaveError "can't contain '~'" - - it "can't have tilde anwhere", -> - fillNameWith '~foo' - expectToHaveError "can't contain '~'" - fillNameWith 'foo~bar' - expectToHaveError "can't contain '~'" - fillNameWith 'foo~' - expectToHaveError "can't contain '~'" - - it "can't have caret anywhere", -> - fillNameWith '^foo' - expectToHaveError "can't contain '^'" - fillNameWith 'foo^bar' - expectToHaveError "can't contain '^'" - fillNameWith 'foo^' - expectToHaveError "can't contain '^'" - - it "can't have : anywhere", -> - fillNameWith ':foo' - expectToHaveError "can't contain ':'" - fillNameWith 'foo:bar' - expectToHaveError "can't contain ':'" - fillNameWith ':foo' - expectToHaveError "can't contain ':'" - - it "can't have question mark anywhere", -> - fillNameWith '?foo' - expectToHaveError "can't contain '?'" - fillNameWith 'foo?bar' - expectToHaveError "can't contain '?'" - fillNameWith 'foo?' - expectToHaveError "can't contain '?'" - - it "can't have asterisk anywhere", -> - fillNameWith '*foo' - expectToHaveError "can't contain '*'" - fillNameWith 'foo*bar' - expectToHaveError "can't contain '*'" - fillNameWith 'foo*' - expectToHaveError "can't contain '*'" - - it "can't have open bracket anywhere", -> - fillNameWith '[foo' - expectToHaveError "can't contain '['" - fillNameWith 'foo[bar' - expectToHaveError "can't contain '['" - fillNameWith 'foo[' - expectToHaveError "can't contain '['" - - it "can't have a backslash anywhere", -> - fillNameWith '\\foo' - expectToHaveError "can't contain '\\'" - fillNameWith 'foo\\bar' - expectToHaveError "can't contain '\\'" - fillNameWith 'foo\\' - expectToHaveError "can't contain '\\'" - - it "can't contain a sequence @{ anywhere", -> - fillNameWith '@{foo' - expectToHaveError "can't contain '@{'" - fillNameWith 'foo@{bar' - expectToHaveError "can't contain '@{'" - fillNameWith 'foo@{' - expectToHaveError "can't contain '@{'" - - it "can't have consecutive slashes", -> - fillNameWith 'foo//bar' - expectToHaveError "can't contain consecutive slashes" - - it "can't end with a slash", -> - fillNameWith 'foo/' - expectToHaveError "can't end in '/'" - - it "can't end with a dot", -> - fillNameWith 'foo.' - expectToHaveError "can't end in '.'" - - it "can't end with .lock", -> - fillNameWith 'foo.lock' - expectToHaveError "can't end in '.lock'" - - it "can't be the single character @", -> - fillNameWith '@' - expectToHaveError "can't be '@'" - - it "concatenates all error messages", -> - fillNameWith '/foo bar?~.' - expectToHaveError "can't start with '/', can't contain spaces, '?', '~', can't end in '.'" - - it "doesn't duplicate error messages", -> - fillNameWith '?foo?bar?zoo?' - expectToHaveError "can't contain '?'" - - it "removes the error message when is a valid name", -> - fillNameWith 'foo?bar' - expect($('.js-branch-name-error span').length).toEqual(1) - fillNameWith 'foobar' - expect($('.js-branch-name-error span').length).toEqual(0) - - it "can have dashes anywhere", -> - fillNameWith '-foo-bar-zoo-' - expect($('.js-branch-name-error span').length).toEqual(0) - - it "can have underscores anywhere", -> - fillNameWith '_foo_bar_zoo_' - expect($('.js-branch-name-error span').length).toEqual(0) - - it "can have numbers anywhere", -> - fillNameWith '1foo2bar3zoo4' - expect($('.js-branch-name-error span').length).toEqual(0) - - it "can be only letters", -> - fillNameWith 'foo' - expect($('.js-branch-name-error span').length).toEqual(0) diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js new file mode 100644 index 00000000000..14dc6bfdfde --- /dev/null +++ b/spec/javascripts/notes_spec.js @@ -0,0 +1,41 @@ + +/*= require notes */ + + +/*= require gl_form */ + +(function() { + window.gon || (window.gon = {}); + + window.disableButtonIfEmptyField = function() { + return null; + }; + + describe('Notes', function() { + return describe('task lists', function() { + fixture.preload('issue_note.html'); + beforeEach(function() { + fixture.load('issue_note.html'); + $('form').on('submit', function(e) { + return e.preventDefault(); + }); + return this.notes = new Notes(); + }); + it('modifies the Markdown field', function() { + $('input[type=checkbox]').attr('checked', true).trigger('change'); + return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + }); + return it('submits the form on tasklist:changed', function() { + var submitted; + submitted = false; + $('form').on('submit', function(e) { + submitted = true; + return e.preventDefault(); + }); + $('.js-task-list-field').trigger('tasklist:changed'); + return expect(submitted).toBe(true); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/notes_spec.js.coffee b/spec/javascripts/notes_spec.js.coffee deleted file mode 100644 index 3a3c8d63e82..00000000000 --- a/spec/javascripts/notes_spec.js.coffee +++ /dev/null @@ -1,26 +0,0 @@ -#= require notes -#= require gl_form - -window.gon or= {} -window.disableButtonIfEmptyField = -> null - -describe 'Notes', -> - describe 'task lists', -> - fixture.preload('issue_note.html') - - beforeEach -> - fixture.load('issue_note.html') - $('form').on 'submit', (e) -> e.preventDefault() - - @notes = new Notes() - - it 'modifies the Markdown field', -> - $('input[type=checkbox]').attr('checked', true).trigger('change') - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item') - - it 'submits the form on tasklist:changed', -> - submitted = false - $('form').on 'submit', (e) -> submitted = true; e.preventDefault() - - $('.js-task-list-field').trigger('tasklist:changed') - expect(submitted).toBe(true) diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js new file mode 100644 index 00000000000..ffe49828492 --- /dev/null +++ b/spec/javascripts/project_title_spec.js @@ -0,0 +1,60 @@ + +/*= require bootstrap */ + + +/*= require select2 */ + + +/*= require lib/utils/type_utility */ + + +/*= require gl_dropdown */ + + +/*= require api */ + + +/*= require project_select */ + + +/*= require project */ + +(function() { + window.gon || (window.gon = {}); + + window.gon.api_version = 'v3'; + + describe('Project Title', function() { + fixture.preload('project_title.html'); + fixture.preload('projects.json'); + beforeEach(function() { + fixture.load('project_title.html'); + return this.project = new Project(); + }); + return describe('project list', function() { + beforeEach((function(_this) { + return function() { + _this.projects_data = fixture.load('projects.json')[0]; + return spyOn(jQuery, 'ajax').and.callFake(function(req) { + var d; + expect(req.url).toBe('/api/v3/projects.json?simple=true'); + d = $.Deferred(); + d.resolve(_this.projects_data); + return d.promise(); + }); + }; + })(this)); + it('to show on toggle click', (function(_this) { + return function() { + $('.js-projects-dropdown-toggle').click(); + return expect($('.header-content').hasClass('open')).toBe(true); + }; + })(this)); + return it('hide dropdown', function() { + $(".dropdown-menu-close-icon").click(); + return expect($('.header-content').hasClass('open')).toBe(false); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/project_title_spec.js.coffee b/spec/javascripts/project_title_spec.js.coffee deleted file mode 100644 index 0244119fa0e..00000000000 --- a/spec/javascripts/project_title_spec.js.coffee +++ /dev/null @@ -1,37 +0,0 @@ -#= require bootstrap -#= require select2 -#= require lib/utils/type_utility -#= require gl_dropdown -#= require api -#= require project_select -#= require project - -window.gon or= {} -window.gon.api_version = 'v3' - -describe 'Project Title', -> - fixture.preload('project_title.html') - fixture.preload('projects.json') - - beforeEach -> - fixture.load('project_title.html') - @project = new Project() - - describe 'project list', -> - beforeEach => - @projects_data = fixture.load('projects.json')[0] - - spyOn(jQuery, 'ajax').and.callFake (req) => - expect(req.url).toBe('/api/v3/projects.json?simple=true') - d = $.Deferred() - d.resolve @projects_data - d.promise() - - it 'to show on toggle click', => - $('.js-projects-dropdown-toggle').click() - expect($('.header-content').hasClass('open')).toBe(true) - - it 'hide dropdown', -> - $(".dropdown-menu-close-icon").click() - - expect($('.header-content').hasClass('open')).toBe(false) diff --git a/spec/javascripts/right_sidebar_spec.js b/spec/javascripts/right_sidebar_spec.js new file mode 100644 index 00000000000..38b3b2653ec --- /dev/null +++ b/spec/javascripts/right_sidebar_spec.js @@ -0,0 +1,70 @@ + +/*= require right_sidebar */ + + +/*= require jquery */ + + +/*= require jquery.cookie */ + +(function() { + var $aside, $icon, $labelsIcon, $page, $toggle, assertSidebarState; + + this.sidebar = null; + + $aside = null; + + $toggle = null; + + $icon = null; + + $page = null; + + $labelsIcon = null; + + assertSidebarState = function(state) { + var shouldBeCollapsed, shouldBeExpanded; + shouldBeExpanded = state === 'expanded'; + shouldBeCollapsed = state === 'collapsed'; + expect($aside.hasClass('right-sidebar-expanded')).toBe(shouldBeExpanded); + expect($page.hasClass('right-sidebar-expanded')).toBe(shouldBeExpanded); + expect($icon.hasClass('fa-angle-double-right')).toBe(shouldBeExpanded); + expect($aside.hasClass('right-sidebar-collapsed')).toBe(shouldBeCollapsed); + expect($page.hasClass('right-sidebar-collapsed')).toBe(shouldBeCollapsed); + return expect($icon.hasClass('fa-angle-double-left')).toBe(shouldBeCollapsed); + }; + + describe('RightSidebar', function() { + fixture.preload('right_sidebar.html'); + beforeEach(function() { + fixture.load('right_sidebar.html'); + this.sidebar = new Sidebar; + $aside = $('.right-sidebar'); + $page = $('.page-with-sidebar'); + $icon = $aside.find('i'); + $toggle = $aside.find('.js-sidebar-toggle'); + return $labelsIcon = $aside.find('.sidebar-collapsed-icon'); + }); + it('should expand the sidebar when arrow is clicked', function() { + $toggle.click(); + return assertSidebarState('expanded'); + }); + it('should collapse the sidebar when arrow is clicked', function() { + $toggle.click(); + assertSidebarState('expanded'); + $toggle.click(); + return assertSidebarState('collapsed'); + }); + it('should float over the page and when sidebar icons clicked', function() { + $labelsIcon.click(); + return assertSidebarState('expanded'); + }); + return it('should collapse when the icon arrow clicked while it is floating on page', function() { + $labelsIcon.click(); + assertSidebarState('expanded'); + $toggle.click(); + return assertSidebarState('collapsed'); + }); + }); + +}).call(this); diff --git a/spec/javascripts/right_sidebar_spec.js.coffee b/spec/javascripts/right_sidebar_spec.js.coffee deleted file mode 100644 index 2075cacdb67..00000000000 --- a/spec/javascripts/right_sidebar_spec.js.coffee +++ /dev/null @@ -1,69 +0,0 @@ -#= require right_sidebar -#= require jquery -#= require jquery.cookie - -@sidebar = null -$aside = null -$toggle = null -$icon = null -$page = null -$labelsIcon = null - - -assertSidebarState = (state) -> - - shouldBeExpanded = state is 'expanded' - shouldBeCollapsed = state is 'collapsed' - - expect($aside.hasClass('right-sidebar-expanded')).toBe shouldBeExpanded - expect($page.hasClass('right-sidebar-expanded')).toBe shouldBeExpanded - expect($icon.hasClass('fa-angle-double-right')).toBe shouldBeExpanded - - expect($aside.hasClass('right-sidebar-collapsed')).toBe shouldBeCollapsed - expect($page.hasClass('right-sidebar-collapsed')).toBe shouldBeCollapsed - expect($icon.hasClass('fa-angle-double-left')).toBe shouldBeCollapsed - - -describe 'RightSidebar', -> - - fixture.preload 'right_sidebar.html' - - beforeEach -> - fixture.load 'right_sidebar.html' - - @sidebar = new Sidebar - $aside = $ '.right-sidebar' - $page = $ '.page-with-sidebar' - $icon = $aside.find 'i' - $toggle = $aside.find '.js-sidebar-toggle' - $labelsIcon = $aside.find '.sidebar-collapsed-icon' - - - it 'should expand the sidebar when arrow is clicked', -> - - $toggle.click() - assertSidebarState 'expanded' - - - it 'should collapse the sidebar when arrow is clicked', -> - - $toggle.click() - assertSidebarState 'expanded' - - $toggle.click() - assertSidebarState 'collapsed' - - - it 'should float over the page and when sidebar icons clicked', -> - - $labelsIcon.click() - assertSidebarState 'expanded' - - - it 'should collapse when the icon arrow clicked while it is floating on page', -> - - $labelsIcon.click() - assertSidebarState 'expanded' - - $toggle.click() - assertSidebarState 'collapsed' diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js new file mode 100644 index 00000000000..68d64483d67 --- /dev/null +++ b/spec/javascripts/search_autocomplete_spec.js @@ -0,0 +1,159 @@ + +/*= require gl_dropdown */ + + +/*= require search_autocomplete */ + + +/*= require jquery */ + + +/*= require lib/utils/common_utils */ + + +/*= require lib/utils/type_utility */ + + +/*= require fuzzaldrin-plus */ + +(function() { + var addBodyAttributes, assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget; + + widget = null; + + userId = 1; + + window.gon || (window.gon = {}); + + window.gon.current_user_id = userId; + + dashboardIssuesPath = '/dashboard/issues'; + + dashboardMRsPath = '/dashboard/merge_requests'; + + projectIssuesPath = '/gitlab-org/gitlab-ce/issues'; + + projectMRsPath = '/gitlab-org/gitlab-ce/merge_requests'; + + groupIssuesPath = '/groups/gitlab-org/issues'; + + groupMRsPath = '/groups/gitlab-org/merge_requests'; + + projectName = 'GitLab Community Edition'; + + groupName = 'Gitlab Org'; + + addBodyAttributes = function(section) { + var $body; + if (section == null) { + section = 'dashboard'; + } + $body = $('body'); + $body.removeAttr('data-page'); + $body.removeAttr('data-project'); + $body.removeAttr('data-group'); + switch (section) { + case 'dashboard': + return $body.data('page', 'root:index'); + case 'group': + $body.data('page', 'groups:show'); + return $body.data('group', 'gitlab-org'); + case 'project': + $body.data('page', 'projects:show'); + return $body.data('project', 'gitlab-ce'); + } + }; + + mockDashboardOptions = function() { + window.gl || (window.gl = {}); + return window.gl.dashboardOptions = { + issuesPath: dashboardIssuesPath, + mrPath: dashboardMRsPath + }; + }; + + mockProjectOptions = function() { + window.gl || (window.gl = {}); + return window.gl.projectOptions = { + 'gitlab-ce': { + issuesPath: projectIssuesPath, + mrPath: projectMRsPath, + projectName: projectName + } + }; + }; + + mockGroupOptions = function() { + window.gl || (window.gl = {}); + return window.gl.groupOptions = { + 'gitlab-org': { + issuesPath: groupIssuesPath, + mrPath: groupMRsPath, + projectName: groupName + } + }; + }; + + assertLinks = function(list, issuesPath, mrsPath) { + var a1, a2, a3, a4, issuesAssignedToMeLink, issuesIHaveCreatedLink, mrsAssignedToMeLink, mrsIHaveCreatedLink; + issuesAssignedToMeLink = issuesPath + "/?assignee_id=" + userId; + issuesIHaveCreatedLink = issuesPath + "/?author_id=" + userId; + mrsAssignedToMeLink = mrsPath + "/?assignee_id=" + userId; + mrsIHaveCreatedLink = mrsPath + "/?author_id=" + userId; + a1 = "a[href='" + issuesAssignedToMeLink + "']"; + a2 = "a[href='" + issuesIHaveCreatedLink + "']"; + a3 = "a[href='" + mrsAssignedToMeLink + "']"; + a4 = "a[href='" + mrsIHaveCreatedLink + "']"; + expect(list.find(a1).length).toBe(1); + expect(list.find(a1).text()).toBe(' Issues assigned to me '); + expect(list.find(a2).length).toBe(1); + expect(list.find(a2).text()).toBe(" Issues I've created "); + expect(list.find(a3).length).toBe(1); + expect(list.find(a3).text()).toBe(' Merge requests assigned to me '); + expect(list.find(a4).length).toBe(1); + return expect(list.find(a4).text()).toBe(" Merge requests I've created "); + }; + + describe('Search autocomplete dropdown', function() { + fixture.preload('search_autocomplete.html'); + beforeEach(function() { + fixture.load('search_autocomplete.html'); + return widget = new SearchAutocomplete; + }); + it('should show Dashboard specific dropdown menu', function() { + var list; + addBodyAttributes(); + mockDashboardOptions(); + widget.searchInput.focus(); + list = widget.wrap.find('.dropdown-menu').find('ul'); + return assertLinks(list, dashboardIssuesPath, dashboardMRsPath); + }); + it('should show Group specific dropdown menu', function() { + var list; + addBodyAttributes('group'); + mockGroupOptions(); + widget.searchInput.focus(); + list = widget.wrap.find('.dropdown-menu').find('ul'); + return assertLinks(list, groupIssuesPath, groupMRsPath); + }); + it('should show Project specific dropdown menu', function() { + var list; + addBodyAttributes('project'); + mockProjectOptions(); + widget.searchInput.focus(); + list = widget.wrap.find('.dropdown-menu').find('ul'); + return assertLinks(list, projectIssuesPath, projectMRsPath); + }); + return it('should not show category related menu if there is text in the input', function() { + var link, list; + addBodyAttributes('project'); + mockProjectOptions(); + widget.searchInput.val('help'); + widget.searchInput.focus(); + list = widget.wrap.find('.dropdown-menu').find('ul'); + link = "a[href='" + projectIssuesPath + "/?assignee_id=" + userId + "']"; + return expect(list.find(link).length).toBe(0); + }); + }); + +}).call(this); diff --git a/spec/javascripts/search_autocomplete_spec.js.coffee b/spec/javascripts/search_autocomplete_spec.js.coffee deleted file mode 100644 index 1c1faca3333..00000000000 --- a/spec/javascripts/search_autocomplete_spec.js.coffee +++ /dev/null @@ -1,149 +0,0 @@ -#= require gl_dropdown -#= require search_autocomplete -#= require jquery -#= require lib/utils/common_utils -#= require lib/utils/type_utility -#= require fuzzaldrin-plus - - -widget = null -userId = 1 -window.gon or= {} -window.gon.current_user_id = userId - -dashboardIssuesPath = '/dashboard/issues' -dashboardMRsPath = '/dashboard/merge_requests' -projectIssuesPath = '/gitlab-org/gitlab-ce/issues' -projectMRsPath = '/gitlab-org/gitlab-ce/merge_requests' -groupIssuesPath = '/groups/gitlab-org/issues' -groupMRsPath = '/groups/gitlab-org/merge_requests' -projectName = 'GitLab Community Edition' -groupName = 'Gitlab Org' - - -# Add required attributes to body before starting the test. -# section would be dashboard|group|project -addBodyAttributes = (section = 'dashboard') -> - - $body = $ 'body' - - $body.removeAttr 'data-page' - $body.removeAttr 'data-project' - $body.removeAttr 'data-group' - - switch section - when 'dashboard' - $body.data 'page', 'root:index' - when 'group' - $body.data 'page', 'groups:show' - $body.data 'group', 'gitlab-org' - when 'project' - $body.data 'page', 'projects:show' - $body.data 'project', 'gitlab-ce' - - -# Mock `gl` object in window for dashboard specific page. App code will need it. -mockDashboardOptions = -> - - window.gl or= {} - window.gl.dashboardOptions = - issuesPath: dashboardIssuesPath - mrPath : dashboardMRsPath - - -# Mock `gl` object in window for project specific page. App code will need it. -mockProjectOptions = -> - - window.gl or= {} - window.gl.projectOptions = - 'gitlab-ce' : - issuesPath : projectIssuesPath - mrPath : projectMRsPath - projectName : projectName - - -mockGroupOptions = -> - - window.gl or= {} - window.gl.groupOptions = - 'gitlab-org' : - issuesPath : groupIssuesPath - mrPath : groupMRsPath - projectName : groupName - - -assertLinks = (list, issuesPath, mrsPath) -> - - issuesAssignedToMeLink = "#{issuesPath}/?assignee_id=#{userId}" - issuesIHaveCreatedLink = "#{issuesPath}/?author_id=#{userId}" - mrsAssignedToMeLink = "#{mrsPath}/?assignee_id=#{userId}" - mrsIHaveCreatedLink = "#{mrsPath}/?author_id=#{userId}" - - a1 = "a[href='#{issuesAssignedToMeLink}']" - a2 = "a[href='#{issuesIHaveCreatedLink}']" - a3 = "a[href='#{mrsAssignedToMeLink}']" - a4 = "a[href='#{mrsIHaveCreatedLink}']" - - expect(list.find(a1).length).toBe 1 - expect(list.find(a1).text()).toBe ' Issues assigned to me ' - - expect(list.find(a2).length).toBe 1 - expect(list.find(a2).text()).toBe " Issues I've created " - - expect(list.find(a3).length).toBe 1 - expect(list.find(a3).text()).toBe ' Merge requests assigned to me ' - - expect(list.find(a4).length).toBe 1 - expect(list.find(a4).text()).toBe " Merge requests I've created " - - -describe 'Search autocomplete dropdown', -> - - fixture.preload 'search_autocomplete.html' - - beforeEach -> - - fixture.load 'search_autocomplete.html' - widget = new SearchAutocomplete - - - it 'should show Dashboard specific dropdown menu', -> - - addBodyAttributes() - mockDashboardOptions() - widget.searchInput.focus() - - list = widget.wrap.find('.dropdown-menu').find 'ul' - assertLinks list, dashboardIssuesPath, dashboardMRsPath - - - it 'should show Group specific dropdown menu', -> - - addBodyAttributes 'group' - mockGroupOptions() - widget.searchInput.focus() - - list = widget.wrap.find('.dropdown-menu').find 'ul' - assertLinks list, groupIssuesPath, groupMRsPath - - - it 'should show Project specific dropdown menu', -> - - addBodyAttributes 'project' - mockProjectOptions() - widget.searchInput.focus() - - list = widget.wrap.find('.dropdown-menu').find 'ul' - assertLinks list, projectIssuesPath, projectMRsPath - - - it 'should not show category related menu if there is text in the input', -> - - addBodyAttributes 'project' - mockProjectOptions() - widget.searchInput.val 'help' - widget.searchInput.focus() - - list = widget.wrap.find('.dropdown-menu').find 'ul' - link = "a[href='#{projectIssuesPath}/?assignee_id=#{userId}']" - expect(list.find(link).length).toBe 0 diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js new file mode 100644 index 00000000000..7b6b55fe545 --- /dev/null +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -0,0 +1,74 @@ + +/*= require shortcuts_issuable */ + +(function() { + describe('ShortcutsIssuable', function() { + fixture.preload('issuable.html'); + beforeEach(function() { + fixture.load('issuable.html'); + return this.shortcut = new ShortcutsIssuable(); + }); + return describe('#replyWithSelectedText', function() { + var stubSelection; + stubSelection = function(text) { + return window.getSelection = function() { + return text; + }; + }; + beforeEach(function() { + return this.selector = 'form.js-main-target-form textarea#note_note'; + }); + describe('with empty selection', function() { + return it('does nothing', function() { + stubSelection(''); + this.shortcut.replyWithSelectedText(); + return expect($(this.selector).val()).toBe(''); + }); + }); + describe('with any selection', function() { + beforeEach(function() { + return stubSelection('Selected text.'); + }); + it('leaves existing input intact', function() { + $(this.selector).val('This text was already here.'); + expect($(this.selector).val()).toBe('This text was already here.'); + this.shortcut.replyWithSelectedText(); + return expect($(this.selector).val()).toBe("This text was already here.\n> Selected text.\n\n"); + }); + it('triggers `input`', function() { + var triggered; + triggered = false; + $(this.selector).on('input', function() { + return triggered = true; + }); + this.shortcut.replyWithSelectedText(); + return expect(triggered).toBe(true); + }); + return it('triggers `focus`', function() { + var focused; + focused = false; + $(this.selector).on('focus', function() { + return focused = true; + }); + this.shortcut.replyWithSelectedText(); + return expect(focused).toBe(true); + }); + }); + describe('with a one-line selection', function() { + return it('quotes the selection', function() { + stubSelection('This text has been selected.'); + this.shortcut.replyWithSelectedText(); + return expect($(this.selector).val()).toBe("> This text has been selected.\n\n"); + }); + }); + return describe('with a multi-line selection', function() { + return it('quotes the selected lines as a group', function() { + stubSelection("Selected line one.\n\nSelected line two.\nSelected line three.\n"); + this.shortcut.replyWithSelectedText(); + return expect($(this.selector).val()).toBe("> Selected line one.\n> Selected line two.\n> Selected line three.\n\n"); + }); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/shortcuts_issuable_spec.js.coffee b/spec/javascripts/shortcuts_issuable_spec.js.coffee deleted file mode 100644 index a01ad7140dd..00000000000 --- a/spec/javascripts/shortcuts_issuable_spec.js.coffee +++ /dev/null @@ -1,82 +0,0 @@ -#= require shortcuts_issuable - -describe 'ShortcutsIssuable', -> - fixture.preload('issuable.html') - - beforeEach -> - fixture.load('issuable.html') - @shortcut = new ShortcutsIssuable() - - describe '#replyWithSelectedText', -> - # Stub window.getSelection to return the provided String. - stubSelection = (text) -> - window.getSelection = -> text - - beforeEach -> - @selector = 'form.js-main-target-form textarea#note_note' - - describe 'with empty selection', -> - it 'does nothing', -> - stubSelection('') - @shortcut.replyWithSelectedText() - expect($(@selector).val()).toBe('') - - describe 'with any selection', -> - beforeEach -> - stubSelection('Selected text.') - - it 'leaves existing input intact', -> - $(@selector).val('This text was already here.') - expect($(@selector).val()).toBe('This text was already here.') - - @shortcut.replyWithSelectedText() - expect($(@selector).val()). - toBe("This text was already here.\n> Selected text.\n\n") - - it 'triggers `input`', -> - triggered = false - $(@selector).on 'input', -> triggered = true - @shortcut.replyWithSelectedText() - - expect(triggered).toBe(true) - - it 'triggers `focus`', -> - focused = false - $(@selector).on 'focus', -> focused = true - @shortcut.replyWithSelectedText() - - expect(focused).toBe(true) - - describe 'with a one-line selection', -> - it 'quotes the selection', -> - stubSelection('This text has been selected.') - - @shortcut.replyWithSelectedText() - - expect($(@selector).val()). - toBe("> This text has been selected.\n\n") - - describe 'with a multi-line selection', -> - it 'quotes the selected lines as a group', -> - stubSelection( - """ - Selected line one. - - Selected line two. - Selected line three. - - """ - ) - - @shortcut.replyWithSelectedText() - - expect($(@selector).val()). - toBe( - """ - > Selected line one. - > Selected line two. - > Selected line three. - - - """ - ) diff --git a/spec/javascripts/spec_helper.coffee b/spec/javascripts/spec_helper.coffee deleted file mode 100644 index 90b02a6aec5..00000000000 --- a/spec/javascripts/spec_helper.coffee +++ /dev/null @@ -1,47 +0,0 @@ -# PhantomJS (Teaspoons default driver) doesn't have support for -# Function.prototype.bind, which has caused confusion. Use this polyfill to -# avoid the confusion. - -#= require support/bind-poly - -# You can require your own javascript files here. By default this will include -# everything in application, however you may get better load performance if you -# require the specific files that are being used in the spec that tests them. - -#= require jquery -#= require jquery.turbolinks -#= require bootstrap -#= require underscore - -# Teaspoon includes some support files, but you can use anything from your own -# support path too. - -# require support/jasmine-jquery-1.7.0 -# require support/jasmine-jquery-2.0.0 -#= require support/jasmine-jquery-2.1.0 -# require support/sinon -# require support/your-support-file - -# Deferring execution - -# If you're using CommonJS, RequireJS or some other asynchronous library you can -# defer execution. Call Teaspoon.execute() after everything has been loaded. -# Simple example of a timeout: - -# Teaspoon.defer = true -# setTimeout(Teaspoon.execute, 1000) - -# Matching files - -# By default Teaspoon will look for files that match -# _spec.{js,js.coffee,.coffee}. Add a filename_spec.js file in your spec path -# and it'll be included in the default suite automatically. If you want to -# customize suites, check out the configuration in teaspoon_env.rb - -# Manifest - -# If you'd rather require your spec files manually (to control order for -# instance) you can disable the suite matcher in the configuration and use this -# file as a manifest. - -# For more information: http://github.com/modeset/teaspoon diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js new file mode 100644 index 00000000000..7d91ed0f855 --- /dev/null +++ b/spec/javascripts/spec_helper.js @@ -0,0 +1,22 @@ + +/*= require support/bind-poly */ + + +/*= require jquery */ + + +/*= require jquery.turbolinks */ + + +/*= require bootstrap */ + + +/*= require underscore */ + + +/*= require support/jasmine-jquery-2.1.0 */ + +(function() { + + +}).call(this); diff --git a/spec/javascripts/syntax_highlight_spec.js b/spec/javascripts/syntax_highlight_spec.js new file mode 100644 index 00000000000..4e5dd1e59bf --- /dev/null +++ b/spec/javascripts/syntax_highlight_spec.js @@ -0,0 +1,44 @@ + +/*= require syntax_highlight */ + +(function() { + describe('Syntax Highlighter', function() { + var stubUserColorScheme; + stubUserColorScheme = function(value) { + if (window.gon == null) { + window.gon = {}; + } + return window.gon.user_color_scheme = value; + }; + describe('on a js-syntax-highlight element', function() { + beforeEach(function() { + return fixture.set('<div class="js-syntax-highlight"></div>'); + }); + return it('applies syntax highlighting', function() { + stubUserColorScheme('monokai'); + $('.js-syntax-highlight').syntaxHighlight(); + return expect($('.js-syntax-highlight')).toHaveClass('monokai'); + }); + }); + return describe('on a parent element', function() { + beforeEach(function() { + return fixture.set("<div class=\"parent\">\n <div class=\"js-syntax-highlight\"></div>\n <div class=\"foo\"></div>\n <div class=\"js-syntax-highlight\"></div>\n</div>"); + }); + it('applies highlighting to all applicable children', function() { + stubUserColorScheme('monokai'); + $('.parent').syntaxHighlight(); + expect($('.parent, .foo')).not.toHaveClass('monokai'); + return expect($('.monokai').length).toBe(2); + }); + return it('prevents an infinite loop when no matches exist', function() { + var highlight; + fixture.set('<div></div>'); + highlight = function() { + return $('div').syntaxHighlight(); + }; + return expect(highlight).not.toThrow(); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/syntax_highlight_spec.js.coffee b/spec/javascripts/syntax_highlight_spec.js.coffee deleted file mode 100644 index 6a73b6bf32c..00000000000 --- a/spec/javascripts/syntax_highlight_spec.js.coffee +++ /dev/null @@ -1,42 +0,0 @@ -#= require syntax_highlight - -describe 'Syntax Highlighter', -> - stubUserColorScheme = (value) -> - window.gon ?= {} - window.gon.user_color_scheme = value - - describe 'on a js-syntax-highlight element', -> - beforeEach -> - fixture.set('<div class="js-syntax-highlight"></div>') - - it 'applies syntax highlighting', -> - stubUserColorScheme('monokai') - - $('.js-syntax-highlight').syntaxHighlight() - - expect($('.js-syntax-highlight')).toHaveClass('monokai') - - describe 'on a parent element', -> - beforeEach -> - fixture.set """ - <div class="parent"> - <div class="js-syntax-highlight"></div> - <div class="foo"></div> - <div class="js-syntax-highlight"></div> - </div> - """ - - it 'applies highlighting to all applicable children', -> - stubUserColorScheme('monokai') - - $('.parent').syntaxHighlight() - - expect($('.parent, .foo')).not.toHaveClass('monokai') - expect($('.monokai').length).toBe(2) - - it 'prevents an infinite loop when no matches exist', -> - fixture.set('<div></div>') - - highlight = -> $('div').syntaxHighlight() - - expect(highlight).not.toThrow() diff --git a/spec/javascripts/u2f/authenticate_spec.coffee b/spec/javascripts/u2f/authenticate_spec.coffee deleted file mode 100644 index 8ffeda11704..00000000000 --- a/spec/javascripts/u2f/authenticate_spec.coffee +++ /dev/null @@ -1,51 +0,0 @@ -#= require u2f/authenticate -#= require u2f/util -#= require u2f/error -#= require u2f -#= require ./mock_u2f_device - -describe 'U2FAuthenticate', -> - fixture.load('u2f/authenticate') - - beforeEach -> - @u2fDevice = new MockU2FDevice - @container = $("#js-authenticate-u2f") - @component = new U2FAuthenticate(@container, {sign_requests: []}, "token") - @component.start() - - it 'allows authenticating via a U2F device', -> - setupButton = @container.find("#js-login-u2f-device") - setupMessage = @container.find("p") - expect(setupMessage.text()).toContain('Insert your security key') - expect(setupButton.text()).toBe('Login Via U2F Device') - setupButton.trigger('click') - - inProgressMessage = @container.find("p") - expect(inProgressMessage.text()).toContain("Trying to communicate with your device") - - @u2fDevice.respondToAuthenticateRequest({deviceData: "this is data from the device"}) - authenticatedMessage = @container.find("p") - deviceResponse = @container.find('#js-device-response') - expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server") - expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}') - - describe "errors", -> - it "displays an error message", -> - setupButton = @container.find("#js-login-u2f-device") - setupButton.trigger('click') - @u2fDevice.respondToAuthenticateRequest({errorCode: "error!"}) - errorMessage = @container.find("p") - expect(errorMessage.text()).toContain("There was a problem communicating with your device") - - it "allows retrying authentication after an error", -> - setupButton = @container.find("#js-login-u2f-device") - setupButton.trigger('click') - @u2fDevice.respondToAuthenticateRequest({errorCode: "error!"}) - retryButton = @container.find("#js-u2f-try-again") - retryButton.trigger('click') - - setupButton = @container.find("#js-login-u2f-device") - setupButton.trigger('click') - @u2fDevice.respondToAuthenticateRequest({deviceData: "this is data from the device"}) - authenticatedMessage = @container.find("p") - expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server") diff --git a/spec/javascripts/u2f/authenticate_spec.js b/spec/javascripts/u2f/authenticate_spec.js new file mode 100644 index 00000000000..e008ce956ad --- /dev/null +++ b/spec/javascripts/u2f/authenticate_spec.js @@ -0,0 +1,75 @@ + +/*= require u2f/authenticate */ + + +/*= require u2f/util */ + + +/*= require u2f/error */ + + +/*= require u2f */ + + +/*= require ./mock_u2f_device */ + +(function() { + describe('U2FAuthenticate', function() { + fixture.load('u2f/authenticate'); + beforeEach(function() { + this.u2fDevice = new MockU2FDevice; + this.container = $("#js-authenticate-u2f"); + this.component = new U2FAuthenticate(this.container, { + sign_requests: [] + }, "token"); + return this.component.start(); + }); + it('allows authenticating via a U2F device', function() { + var authenticatedMessage, deviceResponse, inProgressMessage, setupButton, setupMessage; + setupButton = this.container.find("#js-login-u2f-device"); + setupMessage = this.container.find("p"); + expect(setupMessage.text()).toContain('Insert your security key'); + expect(setupButton.text()).toBe('Login Via U2F Device'); + setupButton.trigger('click'); + inProgressMessage = this.container.find("p"); + expect(inProgressMessage.text()).toContain("Trying to communicate with your device"); + this.u2fDevice.respondToAuthenticateRequest({ + deviceData: "this is data from the device" + }); + authenticatedMessage = this.container.find("p"); + deviceResponse = this.container.find('#js-device-response'); + expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server"); + return expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}'); + }); + return describe("errors", function() { + it("displays an error message", function() { + var errorMessage, setupButton; + setupButton = this.container.find("#js-login-u2f-device"); + setupButton.trigger('click'); + this.u2fDevice.respondToAuthenticateRequest({ + errorCode: "error!" + }); + errorMessage = this.container.find("p"); + return expect(errorMessage.text()).toContain("There was a problem communicating with your device"); + }); + return it("allows retrying authentication after an error", function() { + var authenticatedMessage, retryButton, setupButton; + setupButton = this.container.find("#js-login-u2f-device"); + setupButton.trigger('click'); + this.u2fDevice.respondToAuthenticateRequest({ + errorCode: "error!" + }); + retryButton = this.container.find("#js-u2f-try-again"); + retryButton.trigger('click'); + setupButton = this.container.find("#js-login-u2f-device"); + setupButton.trigger('click'); + this.u2fDevice.respondToAuthenticateRequest({ + deviceData: "this is data from the device" + }); + authenticatedMessage = this.container.find("p"); + return expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server"); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/u2f/mock_u2f_device.js b/spec/javascripts/u2f/mock_u2f_device.js new file mode 100644 index 00000000000..ca91a716ba3 --- /dev/null +++ b/spec/javascripts/u2f/mock_u2f_device.js @@ -0,0 +1,33 @@ +(function() { + var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + + this.MockU2FDevice = (function() { + function MockU2FDevice() { + this.respondToAuthenticateRequest = bind(this.respondToAuthenticateRequest, this); + this.respondToRegisterRequest = bind(this.respondToRegisterRequest, this); + window.u2f || (window.u2f = {}); + window.u2f.register = (function(_this) { + return function(appId, registerRequests, signRequests, callback) { + return _this.registerCallback = callback; + }; + })(this); + window.u2f.sign = (function(_this) { + return function(appId, challenges, signRequests, callback) { + return _this.authenticateCallback = callback; + }; + })(this); + } + + MockU2FDevice.prototype.respondToRegisterRequest = function(params) { + return this.registerCallback(params); + }; + + MockU2FDevice.prototype.respondToAuthenticateRequest = function(params) { + return this.authenticateCallback(params); + }; + + return MockU2FDevice; + + })(); + +}).call(this); diff --git a/spec/javascripts/u2f/mock_u2f_device.js.coffee b/spec/javascripts/u2f/mock_u2f_device.js.coffee deleted file mode 100644 index 97ed0e83a0e..00000000000 --- a/spec/javascripts/u2f/mock_u2f_device.js.coffee +++ /dev/null @@ -1,15 +0,0 @@ -class @MockU2FDevice - constructor: () -> - window.u2f ||= {} - - window.u2f.register = (appId, registerRequests, signRequests, callback) => - @registerCallback = callback - - window.u2f.sign = (appId, challenges, signRequests, callback) => - @authenticateCallback = callback - - respondToRegisterRequest: (params) => - @registerCallback(params) - - respondToAuthenticateRequest: (params) => - @authenticateCallback(params) diff --git a/spec/javascripts/u2f/register_spec.js b/spec/javascripts/u2f/register_spec.js new file mode 100644 index 00000000000..21c5266c60e --- /dev/null +++ b/spec/javascripts/u2f/register_spec.js @@ -0,0 +1,81 @@ + +/*= require u2f/register */ + + +/*= require u2f/util */ + + +/*= require u2f/error */ + + +/*= require u2f */ + + +/*= require ./mock_u2f_device */ + +(function() { + describe('U2FRegister', function() { + fixture.load('u2f/register'); + beforeEach(function() { + this.u2fDevice = new MockU2FDevice; + this.container = $("#js-register-u2f"); + this.component = new U2FRegister(this.container, $("#js-register-u2f-templates"), {}, "token"); + return this.component.start(); + }); + it('allows registering a U2F device', function() { + var deviceResponse, inProgressMessage, registeredMessage, setupButton; + setupButton = this.container.find("#js-setup-u2f-device"); + expect(setupButton.text()).toBe('Setup New U2F Device'); + setupButton.trigger('click'); + inProgressMessage = this.container.children("p"); + expect(inProgressMessage.text()).toContain("Trying to communicate with your device"); + this.u2fDevice.respondToRegisterRequest({ + deviceData: "this is data from the device" + }); + registeredMessage = this.container.find('p'); + deviceResponse = this.container.find('#js-device-response'); + expect(registeredMessage.text()).toContain("Your device was successfully set up!"); + return expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}'); + }); + return describe("errors", function() { + it("doesn't allow the same device to be registered twice (for the same user", function() { + var errorMessage, setupButton; + setupButton = this.container.find("#js-setup-u2f-device"); + setupButton.trigger('click'); + this.u2fDevice.respondToRegisterRequest({ + errorCode: 4 + }); + errorMessage = this.container.find("p"); + return expect(errorMessage.text()).toContain("already been registered with us"); + }); + it("displays an error message for other errors", function() { + var errorMessage, setupButton; + setupButton = this.container.find("#js-setup-u2f-device"); + setupButton.trigger('click'); + this.u2fDevice.respondToRegisterRequest({ + errorCode: "error!" + }); + errorMessage = this.container.find("p"); + return expect(errorMessage.text()).toContain("There was a problem communicating with your device"); + }); + return it("allows retrying registration after an error", function() { + var registeredMessage, retryButton, setupButton; + setupButton = this.container.find("#js-setup-u2f-device"); + setupButton.trigger('click'); + this.u2fDevice.respondToRegisterRequest({ + errorCode: "error!" + }); + retryButton = this.container.find("#U2FTryAgain"); + retryButton.trigger('click'); + setupButton = this.container.find("#js-setup-u2f-device"); + setupButton.trigger('click'); + this.u2fDevice.respondToRegisterRequest({ + deviceData: "this is data from the device" + }); + registeredMessage = this.container.find("p"); + return expect(registeredMessage.text()).toContain("Your device was successfully set up!"); + }); + }); + }); + +}).call(this); diff --git a/spec/javascripts/u2f/register_spec.js.coffee b/spec/javascripts/u2f/register_spec.js.coffee deleted file mode 100644 index 87dc769792b..00000000000 --- a/spec/javascripts/u2f/register_spec.js.coffee +++ /dev/null @@ -1,56 +0,0 @@ -#= require u2f/register -#= require u2f/util -#= require u2f/error -#= require u2f -#= require ./mock_u2f_device - -describe 'U2FRegister', -> - fixture.load('u2f/register') - - beforeEach -> - @u2fDevice = new MockU2FDevice - @container = $("#js-register-u2f") - @component = new U2FRegister(@container, $("#js-register-u2f-templates"), {}, "token") - @component.start() - - it 'allows registering a U2F device', -> - setupButton = @container.find("#js-setup-u2f-device") - expect(setupButton.text()).toBe('Setup New U2F Device') - setupButton.trigger('click') - - inProgressMessage = @container.children("p") - expect(inProgressMessage.text()).toContain("Trying to communicate with your device") - - @u2fDevice.respondToRegisterRequest({deviceData: "this is data from the device"}) - registeredMessage = @container.find('p') - deviceResponse = @container.find('#js-device-response') - expect(registeredMessage.text()).toContain("Your device was successfully set up!") - expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}') - - describe "errors", -> - it "doesn't allow the same device to be registered twice (for the same user", -> - setupButton = @container.find("#js-setup-u2f-device") - setupButton.trigger('click') - @u2fDevice.respondToRegisterRequest({errorCode: 4}) - errorMessage = @container.find("p") - expect(errorMessage.text()).toContain("already been registered with us") - - it "displays an error message for other errors", -> - setupButton = @container.find("#js-setup-u2f-device") - setupButton.trigger('click') - @u2fDevice.respondToRegisterRequest({errorCode: "error!"}) - errorMessage = @container.find("p") - expect(errorMessage.text()).toContain("There was a problem communicating with your device") - - it "allows retrying registration after an error", -> - setupButton = @container.find("#js-setup-u2f-device") - setupButton.trigger('click') - @u2fDevice.respondToRegisterRequest({errorCode: "error!"}) - retryButton = @container.find("#U2FTryAgain") - retryButton.trigger('click') - - setupButton = @container.find("#js-setup-u2f-device") - setupButton.trigger('click') - @u2fDevice.respondToRegisterRequest({deviceData: "this is data from the device"}) - registeredMessage = @container.find("p") - expect(registeredMessage.text()).toContain("Your device was successfully set up!") diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js new file mode 100644 index 00000000000..3d680ec8ea3 --- /dev/null +++ b/spec/javascripts/zen_mode_spec.js @@ -0,0 +1,73 @@ + +/*= require zen_mode */ + +(function() { + var enterZen, escapeKeydown, exitZen; + + describe('ZenMode', function() { + fixture.preload('zen_mode.html'); + beforeEach(function() { + fixture.load('zen_mode.html'); + spyOn(Dropzone, 'forElement').and.callFake(function() { + return { + enable: function() { + return true; + } + }; + }); + this.zen = new ZenMode(); + return this.zen.scroll_position = 456; + }); + describe('on enter', function() { + it('pauses Mousetrap', function() { + spyOn(Mousetrap, 'pause'); + enterZen(); + return expect(Mousetrap.pause).toHaveBeenCalled(); + }); + return it('removes textarea styling', function() { + $('textarea').attr('style', 'height: 400px'); + enterZen(); + return expect('textarea').not.toHaveAttr('style'); + }); + }); + describe('in use', function() { + beforeEach(function() { + return enterZen(); + }); + return it('exits on Escape', function() { + escapeKeydown(); + return expect($('.zen-backdrop')).not.toHaveClass('fullscreen'); + }); + }); + return describe('on exit', function() { + beforeEach(function() { + return enterZen(); + }); + it('unpauses Mousetrap', function() { + spyOn(Mousetrap, 'unpause'); + exitZen(); + return expect(Mousetrap.unpause).toHaveBeenCalled(); + }); + return it('restores the scroll position', function() { + spyOn(this.zen, 'scrollTo'); + exitZen(); + return expect(this.zen.scrollTo).toHaveBeenCalled(); + }); + }); + }); + + enterZen = function() { + return $('a.js-zen-enter').click(); + }; + + exitZen = function() { + return $('a.js-zen-leave').click(); + }; + + escapeKeydown = function() { + return $('textarea').trigger($.Event('keydown', { + keyCode: 27 + })); + }; + +}).call(this); diff --git a/spec/javascripts/zen_mode_spec.js.coffee b/spec/javascripts/zen_mode_spec.js.coffee deleted file mode 100644 index b790fce01ed..00000000000 --- a/spec/javascripts/zen_mode_spec.js.coffee +++ /dev/null @@ -1,51 +0,0 @@ -#= require zen_mode - -describe 'ZenMode', -> - fixture.preload('zen_mode.html') - - beforeEach -> - fixture.load('zen_mode.html') - - # Stub Dropzone.forElement(...).enable() - spyOn(Dropzone, 'forElement').and.callFake -> - enable: -> true - - @zen = new ZenMode() - - # Set this manually because we can't actually scroll the window - @zen.scroll_position = 456 - - describe 'on enter', -> - it 'pauses Mousetrap', -> - spyOn(Mousetrap, 'pause') - enterZen() - expect(Mousetrap.pause).toHaveBeenCalled() - - it 'removes textarea styling', -> - $('textarea').attr('style', 'height: 400px') - enterZen() - expect('textarea').not.toHaveAttr('style') - - describe 'in use', -> - beforeEach -> enterZen() - - it 'exits on Escape', -> - escapeKeydown() - expect($('.zen-backdrop')).not.toHaveClass('fullscreen') - - describe 'on exit', -> - beforeEach -> enterZen() - - it 'unpauses Mousetrap', -> - spyOn(Mousetrap, 'unpause') - exitZen() - expect(Mousetrap.unpause).toHaveBeenCalled() - - it 'restores the scroll position', -> - spyOn(@zen, 'scrollTo') - exitZen() - expect(@zen.scrollTo).toHaveBeenCalled() - -enterZen = -> $('a.js-zen-enter').click() # Ohmmmmmmm -exitZen = -> $('a.js-zen-leave').click() -escapeKeydown = -> $('textarea').trigger($.Event('keydown', {keyCode: 27})) diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/relative_link_filter_spec.rb index b9e4a4eaf0e..bda8d2ce38a 100644 --- a/spec/lib/banzai/filter/relative_link_filter_spec.rb +++ b/spec/lib/banzai/filter/relative_link_filter_spec.rb @@ -1,5 +1,3 @@ -# encoding: UTF-8 - require 'spec_helper' describe Banzai::Filter::RelativeLinkFilter, lib: true do @@ -19,6 +17,10 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do %(<img src="#{path}" />) end + def video(path) + %(<video src="#{path}"></video>) + end + def link(path) %(<a href="#{path}">#{path}</a>) end @@ -39,6 +41,12 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do doc = filter(image('files/images/logo-black.png')) expect(doc.at_css('img')['src']).to eq 'files/images/logo-black.png' end + + it 'does not modify any relative URL in video' do + doc = filter(video('files/videos/intro.mp4'), commit: project.commit('video'), ref: 'video') + + expect(doc.at_css('video')['src']).to eq 'files/videos/intro.mp4' + end end shared_examples :relative_to_requested do @@ -70,12 +78,29 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do end context 'with a valid repository' do + it 'rebuilds absolute URL for a file in the repo' do + doc = filter(link('/doc/api/README.md')) + expect(doc.at_css('a')['href']). + to eq "/#{project_path}/blob/#{ref}/doc/api/README.md" + end + + it 'ignores absolute URLs with two leading slashes' do + doc = filter(link('//doc/api/README.md')) + expect(doc.at_css('a')['href']).to eq '//doc/api/README.md' + end + it 'rebuilds relative URL for a file in the repo' do doc = filter(link('doc/api/README.md')) expect(doc.at_css('a')['href']). to eq "/#{project_path}/blob/#{ref}/doc/api/README.md" end + it 'rebuilds relative URL for a file in the repo with leading ./' do + doc = filter(link('./doc/api/README.md')) + expect(doc.at_css('a')['href']). + to eq "/#{project_path}/blob/#{ref}/doc/api/README.md" + end + it 'rebuilds relative URL for a file in the repo up one directory' do relative_link = link('../api/README.md') doc = filter(relative_link, requested_path: 'doc/update/7.14-to-8.0.md') @@ -113,11 +138,26 @@ describe Banzai::Filter::RelativeLinkFilter, lib: true do end it 'rebuilds relative URL for an image in the repo' do + doc = filter(image('files/images/logo-black.png')) + + expect(doc.at_css('img')['src']). + to eq "/#{project_path}/raw/#{ref}/files/images/logo-black.png" + end + + it 'rebuilds relative URL for link to an image in the repo' do doc = filter(link('files/images/logo-black.png')) + expect(doc.at_css('a')['href']). to eq "/#{project_path}/raw/#{ref}/files/images/logo-black.png" end + it 'rebuilds relative URL for a video in the repo' do + doc = filter(video('files/videos/intro.mp4'), commit: project.commit('video'), ref: 'video') + + expect(doc.at_css('video')['src']). + to eq "/#{project_path}/raw/video/files/videos/intro.mp4" + end + it 'does not modify relative URL with an anchor only' do doc = filter(link('#section-1')) expect(doc.at_css('a')['href']).to eq '#section-1' diff --git a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb index 6a5d003e87f..356dd01a03a 100644 --- a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb +++ b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb @@ -1,5 +1,3 @@ -# encoding: UTF-8 - require 'spec_helper' describe Banzai::Filter::TableOfContentsFilter, lib: true do diff --git a/spec/lib/banzai/filter/upload_link_filter_spec.rb b/spec/lib/banzai/filter/upload_link_filter_spec.rb index 273d2ed709a..8b76c1d73c9 100644 --- a/spec/lib/banzai/filter/upload_link_filter_spec.rb +++ b/spec/lib/banzai/filter/upload_link_filter_spec.rb @@ -1,5 +1,3 @@ -# encoding: UTF-8 - require 'spec_helper' describe Banzai::Filter::UploadLinkFilter, lib: true do diff --git a/spec/lib/banzai/filter/video_link_filter_spec.rb b/spec/lib/banzai/filter/video_link_filter_spec.rb index cc4349f80ba..6ab1be9ccb7 100644 --- a/spec/lib/banzai/filter/video_link_filter_spec.rb +++ b/spec/lib/banzai/filter/video_link_filter_spec.rb @@ -47,5 +47,4 @@ describe Banzai::Filter::VideoLinkFilter, lib: true do expect(element['src']).to eq '/path/my_image.jpg' end end - end diff --git a/spec/lib/banzai/reference_parser/issue_parser_spec.rb b/spec/lib/banzai/reference_parser/issue_parser_spec.rb index 514c752546d..85cfe728b6a 100644 --- a/spec/lib/banzai/reference_parser/issue_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/issue_parser_spec.rb @@ -16,17 +16,17 @@ describe Banzai::ReferenceParser::IssueParser, lib: true do end it 'returns the nodes when the user can read the issue' do - expect(Ability.abilities).to receive(:allowed?). - with(user, :read_issue, issue). - and_return(true) + expect(Ability).to receive(:issues_readable_by_user). + with([issue], user). + and_return([issue]) expect(subject.nodes_visible_to_user(user, [link])).to eq([link]) end it 'returns an empty Array when the user can not read the issue' do - expect(Ability.abilities).to receive(:allowed?). - with(user, :read_issue, issue). - and_return(false) + expect(Ability).to receive(:issues_readable_by_user). + with([issue], user). + and_return([]) expect(subject.nodes_visible_to_user(user, [link])).to eq([]) end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index d20fd4ab7dd..85374b8761d 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -162,7 +162,7 @@ module Ci shared_examples 'raises an error' do it do - expect { processor }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'rspec job: only parameter should be an array of strings or regexps') + expect { processor }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'jobs:rspec:only config should be an array of strings or regexps') end end @@ -318,7 +318,7 @@ module Ci shared_examples 'raises an error' do it do - expect { processor }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'rspec job: except parameter should be an array of strings or regexps') + expect { processor }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'jobs:rspec:except config should be an array of strings or regexps') end end @@ -533,10 +533,6 @@ module Ci } end - context 'when also global variables are defined' do - - end - context 'when syntax is correct' do let(:variables) do { VAR1: 'value1', VAR2: 'value2' } @@ -559,7 +555,7 @@ module Ci it 'raises error' do expect { subject } .to raise_error(GitlabCiYamlProcessor::ValidationError, - /job: variables should be a map/) + /jobs:rspec:variables config should be a hash of key value pairs/) end end @@ -774,7 +770,7 @@ module Ci let(:environment) { 1 } it 'raises error' do - expect { builds }.to raise_error("deploy_to_production job: environment parameter #{Gitlab::Regex.environment_name_regex_message}") + expect { builds }.to raise_error("jobs:deploy_to_production environment #{Gitlab::Regex.environment_name_regex_message}") end end @@ -782,7 +778,7 @@ module Ci let(:environment) { 'production staging' } it 'raises error' do - expect { builds }.to raise_error("deploy_to_production job: environment parameter #{Gitlab::Regex.environment_name_regex_message}") + expect { builds }.to raise_error("jobs:deploy_to_production environment #{Gitlab::Regex.environment_name_regex_message}") end end end @@ -973,7 +969,7 @@ EOT config = YAML.dump({ rspec: { script: "test", tags: "mysql" } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: tags parameter should be an array of strings") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec tags should be an array of strings") end it "returns errors if before_script parameter is invalid" do @@ -987,7 +983,7 @@ EOT config = YAML.dump({ rspec: { script: "test", before_script: [10, "test"] } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: before_script should be an array of strings") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:before_script config should be an array of strings") end it "returns errors if after_script parameter is invalid" do @@ -1001,7 +997,7 @@ EOT config = YAML.dump({ rspec: { script: "test", after_script: [10, "test"] } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: after_script should be an array of strings") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:after_script config should be an array of strings") end it "returns errors if image parameter is invalid" do @@ -1015,21 +1011,21 @@ EOT config = YAML.dump({ '' => { script: "test" } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "job name should be non-empty string") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:job name can't be blank") end it "returns errors if job name is non-string" do config = YAML.dump({ 10 => { script: "test" } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "job name should be non-empty string") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:10 name should be a symbol") end it "returns errors if job image parameter is invalid" do config = YAML.dump({ rspec: { script: "test", image: ["test"] } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: image should be a string") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:image config should be a string") end it "returns errors if services parameter is not an array" do @@ -1050,49 +1046,56 @@ EOT config = YAML.dump({ rspec: { script: "test", services: "test" } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: services should be an array of strings") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:services config should be an array of strings") end it "returns errors if job services parameter is not an array of strings" do config = YAML.dump({ rspec: { script: "test", services: [10, "test"] } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: services should be an array of strings") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:services config should be an array of strings") end - it "returns errors if there are unknown parameters" do + it "returns error if job configuration is invalid" do config = YAML.dump({ extra: "bundle update" }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "Unknown parameter: extra") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:extra config should be a hash") end it "returns errors if there are unknown parameters that are hashes, but doesn't have a script" do config = YAML.dump({ extra: { services: "test" } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "Unknown parameter: extra") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:extra:services config should be an array of strings") end it "returns errors if there are no jobs defined" do config = YAML.dump({ before_script: ["bundle update"] }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "Please define at least one job") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs config should contain at least one visible job") + end + + it "returns errors if there are no visible jobs defined" do + config = YAML.dump({ before_script: ["bundle update"], '.hidden'.to_sym => { script: 'ls' } }) + expect do + GitlabCiYamlProcessor.new(config, path) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs config should contain at least one visible job") end it "returns errors if job allow_failure parameter is not an boolean" do config = YAML.dump({ rspec: { script: "test", allow_failure: "string" } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: allow_failure parameter should be an boolean") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec allow failure should be a boolean value") end it "returns errors if job stage is not a string" do config = YAML.dump({ rspec: { script: "test", type: 1 } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: stage parameter should be build, test, deploy") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:type config should be a string") end it "returns errors if job stage is not a pre-defined stage" do @@ -1141,49 +1144,49 @@ EOT config = YAML.dump({ rspec: { script: "test", when: 1 } }) expect do GitlabCiYamlProcessor.new(config, path) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: when parameter should be on_success, on_failure, always or manual") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec when should be on_success, on_failure, always or manual") end it "returns errors if job artifacts:name is not an a string" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { name: 1 } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:name parameter should be a string") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts name should be a string") end it "returns errors if job artifacts:when is not an a predefined value" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { when: 1 } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:when parameter should be on_success, on_failure or always") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts when should be on_success, on_failure or always") end it "returns errors if job artifacts:expire_in is not an a string" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { expire_in: 1 } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:expire_in parameter should be a duration") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts expire in should be a duration") end it "returns errors if job artifacts:expire_in is not an a valid duration" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:expire_in parameter should be a duration") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts expire in should be a duration") end it "returns errors if job artifacts:untracked is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { untracked: "string" } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:untracked parameter should be an boolean") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts untracked should be a boolean value") end it "returns errors if job artifacts:paths is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { paths: "string" } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:paths parameter should be an array of strings") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts paths should be an array of strings") end it "returns errors if cache:untracked is not an array of strings" do @@ -1211,28 +1214,28 @@ EOT config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", cache: { key: 1 } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: cache:key parameter should be a string") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:cache:key config should be a string or symbol") end it "returns errors if job cache:untracked is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", cache: { untracked: "string" } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: cache:untracked parameter should be an boolean") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:cache:untracked config should be a boolean value") end it "returns errors if job cache:paths is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", cache: { paths: "string" } } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: cache:paths parameter should be an array of strings") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:cache:paths config should be an array of strings") end it "returns errors if job dependencies is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", dependencies: "string" } }) expect do GitlabCiYamlProcessor.new(config) - end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: dependencies parameter should be an array of strings") + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec dependencies should be an array of strings") end end diff --git a/spec/lib/gitlab/akismet_helper_spec.rb b/spec/lib/gitlab/akismet_helper_spec.rb index 88a71528867..b08396da4d2 100644 --- a/spec/lib/gitlab/akismet_helper_spec.rb +++ b/spec/lib/gitlab/akismet_helper_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Gitlab::AkismetHelper, type: :helper do - let(:project) { create(:project) } + let(:project) { create(:project, :public) } let(:user) { create(:user) } before do @@ -11,13 +11,13 @@ describe Gitlab::AkismetHelper, type: :helper do end describe '#check_for_spam?' do - it 'returns true for non-member' do - expect(helper.check_for_spam?(project, user)).to eq(true) + it 'returns true for public project' do + expect(helper.check_for_spam?(project)).to eq(true) end - it 'returns false for member' do - project.team << [user, :guest] - expect(helper.check_for_spam?(project, user)).to eq(false) + it 'returns false for private project' do + project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) + expect(helper.check_for_spam?(project)).to eq(false) end end diff --git a/spec/lib/gitlab/ci/config/node/artifacts_spec.rb b/spec/lib/gitlab/ci/config/node/artifacts_spec.rb new file mode 100644 index 00000000000..c09a0a9c793 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/artifacts_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Artifacts do + let(:entry) { described_class.new(config) } + + describe 'validation' do + context 'when entry config value is correct' do + let(:config) { { paths: %w[public/] } } + + describe '#value' do + it 'returns artifacs configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when value of attribute is invalid' do + let(:config) { { name: 10 } } + + it 'reports error' do + expect(entry.errors) + .to include 'artifacts name should be a string' + end + end + + context 'when there is an unknown key present' do + let(:config) { { test: 100 } } + + it 'reports error' do + expect(entry.errors) + .to include 'artifacts config contains unknown keys: test' + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/attributable_spec.rb b/spec/lib/gitlab/ci/config/node/attributable_spec.rb new file mode 100644 index 00000000000..24d9daafd88 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/attributable_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Attributable do + let(:node) { Class.new } + let(:instance) { node.new } + + before do + node.include(described_class) + + node.class_eval do + attributes :name, :test + end + end + + context 'config is a hash' do + before do + allow(instance) + .to receive(:config) + .and_return({ name: 'some name', test: 'some test' }) + end + + it 'returns the value of config' do + expect(instance.name).to eq 'some name' + expect(instance.test).to eq 'some test' + end + + it 'returns no method error for unknown attributes' do + expect { instance.unknown }.to raise_error(NoMethodError) + end + end + + context 'config is not a hash' do + before do + allow(instance) + .to receive(:config) + .and_return('some test') + end + + it 'returns nil' do + expect(instance.test).to be_nil + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/commands_spec.rb b/spec/lib/gitlab/ci/config/node/commands_spec.rb new file mode 100644 index 00000000000..e373c40706f --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/commands_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Commands do + let(:entry) { described_class.new(config) } + + context 'when entry config value is an array' do + let(:config) { ['ls', 'pwd'] } + + describe '#value' do + it 'returns array of strings' do + expect(entry.value).to eq config + end + end + + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + end + + context 'when entry config value is a string' do + let(:config) { 'ls' } + + describe '#value' do + it 'returns array with single element' do + expect(entry.value).to eq ['ls'] + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not valid' do + let(:config) { 1 } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include 'commands config should be a ' \ + 'string or an array of strings' + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/factory_spec.rb b/spec/lib/gitlab/ci/config/node/factory_spec.rb index 91ddef7bfbf..d26185ba585 100644 --- a/spec/lib/gitlab/ci/config/node/factory_spec.rb +++ b/spec/lib/gitlab/ci/config/node/factory_spec.rb @@ -2,13 +2,13 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Factory do describe '#create!' do - let(:factory) { described_class.new(entry_class) } - let(:entry_class) { Gitlab::Ci::Config::Node::Script } + let(:factory) { described_class.new(node) } + let(:node) { Gitlab::Ci::Config::Node::Script } - context 'when setting up a value' do + context 'when setting a concrete value' do it 'creates entry with valid value' do entry = factory - .with(value: ['ls', 'pwd']) + .value(['ls', 'pwd']) .create! expect(entry.value).to eq ['ls', 'pwd'] @@ -17,7 +17,7 @@ describe Gitlab::Ci::Config::Node::Factory do context 'when setting description' do it 'creates entry with description' do entry = factory - .with(value: ['ls', 'pwd']) + .value(['ls', 'pwd']) .with(description: 'test description') .create! @@ -29,7 +29,8 @@ describe Gitlab::Ci::Config::Node::Factory do context 'when setting key' do it 'creates entry with custom key' do entry = factory - .with(value: ['ls', 'pwd'], key: 'test key') + .value(['ls', 'pwd']) + .with(key: 'test key') .create! expect(entry.key).to eq 'test key' @@ -37,19 +38,20 @@ describe Gitlab::Ci::Config::Node::Factory do end context 'when setting a parent' do - let(:parent) { Object.new } + let(:object) { Object.new } it 'creates entry with valid parent' do entry = factory - .with(value: 'ls', parent: parent) + .value('ls') + .with(parent: object) .create! - expect(entry.parent).to eq parent + expect(entry.parent).to eq object end end end - context 'when not setting up a value' do + context 'when not setting a value' do it 'raises error' do expect { factory.create! }.to raise_error( Gitlab::Ci::Config::Node::Factory::InvalidFactory @@ -60,11 +62,25 @@ describe Gitlab::Ci::Config::Node::Factory do context 'when creating entry with nil value' do it 'creates an undefined entry' do entry = factory - .with(value: nil) + .value(nil) .create! expect(entry).to be_an_instance_of Gitlab::Ci::Config::Node::Undefined end end + + context 'when passing metadata' do + let(:node) { spy('node') } + + it 'passes metadata as a parameter' do + factory + .value('some value') + .metadata(some: 'hash') + .create! + + expect(node).to have_received(:new) + .with('some value', { some: 'hash' }) + end + end end end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index c87c9e97bc8..2f87d270b36 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -22,38 +22,40 @@ describe Gitlab::Ci::Config::Node::Global do variables: { VAR: 'value' }, after_script: ['make clean'], stages: ['build', 'pages'], - cache: { key: 'k', untracked: true, paths: ['public/'] } } + cache: { key: 'k', untracked: true, paths: ['public/'] }, + rspec: { script: %w[rspec ls] }, + spinach: { script: 'spinach' } } end describe '#process!' do before { global.process! } it 'creates nodes hash' do - expect(global.nodes).to be_an Array + expect(global.descendants).to be_an Array end it 'creates node object for each entry' do - expect(global.nodes.count).to eq 8 + expect(global.descendants.count).to eq 8 end it 'creates node object using valid class' do - expect(global.nodes.first) + expect(global.descendants.first) .to be_an_instance_of Gitlab::Ci::Config::Node::Script - expect(global.nodes.second) + expect(global.descendants.second) .to be_an_instance_of Gitlab::Ci::Config::Node::Image end it 'sets correct description for nodes' do - expect(global.nodes.first.description) + expect(global.descendants.first.description) .to eq 'Script that will be executed before each job.' - expect(global.nodes.second.description) + expect(global.descendants.second.description) .to eq 'Docker image that will be used to execute jobs.' end - end - describe '#leaf?' do - it 'is not leaf' do - expect(global).not_to be_leaf + describe '#leaf?' do + it 'is not leaf' do + expect(global).not_to be_leaf + end end end @@ -63,6 +65,12 @@ describe Gitlab::Ci::Config::Node::Global do expect(global.before_script).to be nil end end + + describe '#leaf?' do + it 'is leaf' do + expect(global).to be_leaf + end + end end context 'when processed' do @@ -106,7 +114,10 @@ describe Gitlab::Ci::Config::Node::Global do end context 'when deprecated types key defined' do - let(:hash) { { types: ['test', 'deploy'] } } + let(:hash) do + { types: ['test', 'deploy'], + rspec: { script: 'rspec' } } + end it 'returns array of types as stages' do expect(global.stages).to eq %w[test deploy] @@ -120,20 +131,33 @@ describe Gitlab::Ci::Config::Node::Global do .to eq(key: 'k', untracked: true, paths: ['public/']) end end + + describe '#jobs' do + it 'returns jobs configuration' do + expect(global.jobs).to eq( + rspec: { name: :rspec, + script: %w[rspec ls], + stage: 'test' }, + spinach: { name: :spinach, + script: %w[spinach], + stage: 'test' } + ) + end + end end end context 'when most of entires not defined' do - let(:hash) { { cache: { key: 'a' }, rspec: {} } } + let(:hash) { { cache: { key: 'a' }, rspec: { script: %w[ls] } } } before { global.process! } describe '#nodes' do it 'instantizes all nodes' do - expect(global.nodes.count).to eq 8 + expect(global.descendants.count).to eq 8 end it 'contains undefined nodes' do - expect(global.nodes.first) + expect(global.descendants.first) .to be_an_instance_of Gitlab::Ci::Config::Node::Undefined end end @@ -164,7 +188,7 @@ describe Gitlab::Ci::Config::Node::Global do # details. # context 'when entires specified but not defined' do - let(:hash) { { variables: nil } } + let(:hash) { { variables: nil, rspec: { script: 'rspec' } } } before { global.process! } describe '#variables' do @@ -196,10 +220,8 @@ describe Gitlab::Ci::Config::Node::Global do end describe '#before_script' do - it 'raises error' do - expect { global.before_script }.to raise_error( - Gitlab::Ci::Config::Node::Entry::InvalidError - ) + it 'returns nil' do + expect(global.before_script).to be_nil end end end @@ -220,9 +242,9 @@ describe Gitlab::Ci::Config::Node::Global do end end - describe '#defined?' do + describe '#specified?' do it 'is concrete entry that is defined' do - expect(global.defined?).to be true + expect(global.specified?).to be true end end end diff --git a/spec/lib/gitlab/ci/config/node/hidden_job_spec.rb b/spec/lib/gitlab/ci/config/node/hidden_job_spec.rb new file mode 100644 index 00000000000..cc44e2cc054 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/hidden_job_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::HiddenJob do + let(:entry) { described_class.new(config) } + + describe 'validations' do + context 'when entry config value is correct' do + let(:config) { { image: 'ruby:2.2' } } + + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq(image: 'ruby:2.2') + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + context 'incorrect config value type' do + let(:config) { ['incorrect'] } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include 'hidden job config should be a hash' + end + end + end + + context 'when config is empty' do + let(:config) { {} } + + describe '#valid' do + it 'is invalid' do + expect(entry).not_to be_valid + end + end + end + end + end + + describe '#leaf?' do + it 'is a leaf' do + expect(entry).to be_leaf + end + end + + describe '#relevant?' do + it 'is not a relevant entry' do + expect(entry).not_to be_relevant + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/job_spec.rb b/spec/lib/gitlab/ci/config/node/job_spec.rb new file mode 100644 index 00000000000..1484fb60dd8 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/job_spec.rb @@ -0,0 +1,86 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Job do + let(:entry) { described_class.new(config, name: :rspec) } + + before { entry.process! } + + describe 'validations' do + context 'when entry config value is correct' do + let(:config) { { script: 'rspec' } } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + + context 'when job name is empty' do + let(:entry) { described_class.new(config, name: ''.to_sym) } + + it 'reports error' do + expect(entry.errors) + .to include "job name can't be blank" + end + end + end + + context 'when entry value is not correct' do + context 'incorrect config value type' do + let(:config) { ['incorrect'] } + + describe '#errors' do + it 'reports error about a config type' do + expect(entry.errors) + .to include 'job config should be a hash' + end + end + end + + context 'when config is empty' do + let(:config) { {} } + + describe '#valid' do + it 'is invalid' do + expect(entry).not_to be_valid + end + end + end + + context 'when unknown keys detected' do + let(:config) { { unknown: true } } + + describe '#valid' do + it 'is not valid' do + expect(entry).not_to be_valid + end + end + end + end + end + + describe '#value' do + context 'when entry is correct' do + let(:config) do + { before_script: %w[ls pwd], + script: 'rspec', + after_script: %w[cleanup] } + end + + it 'returns correct value' do + expect(entry.value) + .to eq(name: :rspec, + before_script: %w[ls pwd], + script: %w[rspec], + stage: 'test', + after_script: %w[cleanup]) + end + end + end + + describe '#relevant?' do + it 'is a relevant entry' do + expect(entry).to be_relevant + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/jobs_spec.rb b/spec/lib/gitlab/ci/config/node/jobs_spec.rb new file mode 100644 index 00000000000..b8d9c70479c --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/jobs_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Jobs do + let(:entry) { described_class.new(config) } + + describe 'validations' do + before { entry.process! } + + context 'when entry config value is correct' do + let(:config) { { rspec: { script: 'rspec' } } } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'incorrect config value type' do + let(:config) { ['incorrect'] } + + it 'returns error about incorrect type' do + expect(entry.errors) + .to include 'jobs config should be a hash' + end + end + + context 'when job is unspecified' do + let(:config) { { rspec: nil } } + + it 'reports error' do + expect(entry.errors).to include "rspec config can't be blank" + end + end + + context 'when no visible jobs present' do + let(:config) { { '.hidden'.to_sym => { script: [] } } } + + it 'returns error about no visible jobs defined' do + expect(entry.errors) + .to include 'jobs config should contain at least one visible job' + end + end + end + end + end + + context 'when valid job entries processed' do + before { entry.process! } + + let(:config) do + { rspec: { script: 'rspec' }, + spinach: { script: 'spinach' }, + '.hidden'.to_sym => {} } + end + + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq( + rspec: { name: :rspec, + script: %w[rspec], + stage: 'test' }, + spinach: { name: :spinach, + script: %w[spinach], + stage: 'test' }) + end + end + + describe '#descendants' do + it 'creates valid descendant nodes' do + expect(entry.descendants.count).to eq 3 + expect(entry.descendants.first(2)) + .to all(be_an_instance_of(Gitlab::Ci::Config::Node::Job)) + expect(entry.descendants.last) + .to be_an_instance_of(Gitlab::Ci::Config::Node::HiddenJob) + end + end + + describe '#value' do + it 'returns value of visible jobs only' do + expect(entry.value.keys).to eq [:rspec, :spinach] + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/null_spec.rb b/spec/lib/gitlab/ci/config/node/null_spec.rb new file mode 100644 index 00000000000..1ab5478dcfa --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/null_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Null do + let(:null) { described_class.new(nil) } + + describe '#leaf?' do + it 'is leaf node' do + expect(null).to be_leaf + end + end + + describe '#valid?' do + it 'is always valid' do + expect(null).to be_valid + end + end + + describe '#errors' do + it 'is does not contain errors' do + expect(null.errors).to be_empty + end + end + + describe '#value' do + it 'returns nil' do + expect(null.value).to eq nil + end + end + + describe '#relevant?' do + it 'is not relevant' do + expect(null.relevant?).to eq false + end + end + + describe '#specified?' do + it 'is not defined' do + expect(null.specified?).to eq false + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/stage_spec.rb b/spec/lib/gitlab/ci/config/node/stage_spec.rb new file mode 100644 index 00000000000..fb9ec70762a --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/stage_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Stage do + let(:stage) { described_class.new(config) } + + describe 'validations' do + context 'when stage config value is correct' do + let(:config) { 'build' } + + describe '#value' do + it 'returns a stage key' do + expect(stage.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(stage).to be_valid + end + end + end + + context 'when value has a wrong type' do + let(:config) { { test: true } } + + it 'reports errors about wrong type' do + expect(stage.errors) + .to include 'stage config should be a string' + end + end + end + + describe '.default' do + it 'returns default stage' do + expect(described_class.default).to eq 'test' + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/trigger_spec.rb b/spec/lib/gitlab/ci/config/node/trigger_spec.rb new file mode 100644 index 00000000000..a4a3e36754e --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/trigger_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Trigger do + let(:entry) { described_class.new(config) } + + describe 'validations' do + context 'when entry config value is valid' do + context 'when config is a branch or tag name' do + let(:config) { %w[master feature/branch] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + + describe '#value' do + it 'returns key value' do + expect(entry.value).to eq config + end + end + end + + context 'when config is a regexp' do + let(:config) { ['/^issue-.*$/'] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when config is a special keyword' do + let(:config) { %w[tags triggers branches] } + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + end + + context 'when entry value is not valid' do + let(:config) { [1] } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include 'trigger config should be an array of strings or regexps' + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/undefined_spec.rb b/spec/lib/gitlab/ci/config/node/undefined_spec.rb index 0c6608d906d..2d43e1c1a9d 100644 --- a/spec/lib/gitlab/ci/config/node/undefined_spec.rb +++ b/spec/lib/gitlab/ci/config/node/undefined_spec.rb @@ -2,39 +2,31 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Undefined do let(:undefined) { described_class.new(entry) } - let(:entry) { Class.new } - - describe '#leaf?' do - it 'is leaf node' do - expect(undefined).to be_leaf - end - end + let(:entry) { spy('Entry') } describe '#valid?' do - it 'is always valid' do - expect(undefined).to be_valid + it 'delegates method to entry' do + expect(undefined.valid).to eq entry end end describe '#errors' do - it 'is does not contain errors' do - expect(undefined.errors).to be_empty + it 'delegates method to entry' do + expect(undefined.errors).to eq entry end end describe '#value' do - before do - allow(entry).to receive(:default).and_return('some value') - end - - it 'returns default value for entry' do - expect(undefined.value).to eq 'some value' + it 'delegates method to entry' do + expect(undefined.value).to eq entry end end - describe '#undefined?' do - it 'is not a defined entry' do - expect(undefined.defined?).to be false + describe '#specified?' do + it 'is always false' do + allow(entry).to receive(:specified?).and_return(true) + + expect(undefined.specified?).to be false end end end diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb index e9b8ce6b5bb..de3f64249a2 100644 --- a/spec/lib/gitlab/closing_issue_extractor_spec.rb +++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb @@ -3,10 +3,12 @@ require 'spec_helper' describe Gitlab::ClosingIssueExtractor, lib: true do let(:project) { create(:project) } let(:project2) { create(:project) } + let(:forked_project) { Projects::ForkService.new(project, project.creator).execute } let(:issue) { create(:issue, project: project) } let(:issue2) { create(:issue, project: project2) } let(:reference) { issue.to_reference } let(:cross_reference) { issue2.to_reference(project) } + let(:fork_cross_reference) { issue.to_reference(forked_project) } subject { described_class.new(project, project.creator) } @@ -278,6 +280,15 @@ describe Gitlab::ClosingIssueExtractor, lib: true do end end + context "with a cross-project fork reference" do + subject { described_class.new(forked_project, forked_project.creator) } + + it do + message = "Closes #{fork_cross_reference}" + expect(subject.closed_by_message(message)).to be_empty + end + end + context "with an invalid URL" do it do message = "Closes https://google.com#{urls.namespace_project_issue_path(issue2.project.namespace, issue2.project, issue2)}" diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index e883a6eb9c2..0650cb291e5 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::Diff::File, lib: true do let(:project) { create(:project) } let(:commit) { project.commit(sample_commit.id) } - let(:diff) { commit.diffs.first } + let(:diff) { commit.raw_diffs.first } let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: commit.diff_refs, repository: project.repository) } describe '#diff_lines' do diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb index 88e4115c453..1c2ddeed692 100644 --- a/spec/lib/gitlab/diff/highlight_spec.rb +++ b/spec/lib/gitlab/diff/highlight_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::Diff::Highlight, lib: true do let(:project) { create(:project) } let(:commit) { project.commit(sample_commit.id) } - let(:diff) { commit.diffs.first } + let(:diff) { commit.raw_diffs.first } let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: commit.diff_refs, repository: project.repository) } describe '#highlight' do diff --git a/spec/lib/gitlab/diff/line_mapper_spec.rb b/spec/lib/gitlab/diff/line_mapper_spec.rb index 4e50e03bb7e..4b943fa382d 100644 --- a/spec/lib/gitlab/diff/line_mapper_spec.rb +++ b/spec/lib/gitlab/diff/line_mapper_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Diff::LineMapper, lib: true do let(:project) { create(:project) } let(:repository) { project.repository } let(:commit) { project.commit(sample_commit.id) } - let(:diffs) { commit.diffs } + let(:diffs) { commit.raw_diffs } let(:diff) { diffs.first } let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: commit.diff_refs, repository: repository) } subject { described_class.new(diff_file) } diff --git a/spec/lib/gitlab/diff/parallel_diff_spec.rb b/spec/lib/gitlab/diff/parallel_diff_spec.rb index 2aa5ae44f54..af18d3c25a6 100644 --- a/spec/lib/gitlab/diff/parallel_diff_spec.rb +++ b/spec/lib/gitlab/diff/parallel_diff_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Diff::ParallelDiff, lib: true do let(:project) { create(:project) } let(:repository) { project.repository } let(:commit) { project.commit(sample_commit.id) } - let(:diffs) { commit.diffs } + let(:diffs) { commit.raw_diffs } let(:diff) { diffs.first } let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: commit.diff_refs, repository: repository) } subject { described_class.new(diff_file) } diff --git a/spec/lib/gitlab/diff/parser_spec.rb b/spec/lib/gitlab/diff/parser_spec.rb index c3359627652..b983d73f8be 100644 --- a/spec/lib/gitlab/diff/parser_spec.rb +++ b/spec/lib/gitlab/diff/parser_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::Diff::Parser, lib: true do let(:project) { create(:project) } let(:commit) { project.commit(sample_commit.id) } - let(:diff) { commit.diffs.first } + let(:diff) { commit.raw_diffs.first } let(:parser) { Gitlab::Diff::Parser.new } describe '#parse' do diff --git a/spec/lib/gitlab/email/email_shared_blocks.rb b/spec/lib/gitlab/email/email_shared_blocks.rb new file mode 100644 index 00000000000..19298e261e3 --- /dev/null +++ b/spec/lib/gitlab/email/email_shared_blocks.rb @@ -0,0 +1,41 @@ +require 'gitlab/email/receiver' + +shared_context :email_shared_context do + let(:mail_key) { "59d8df8370b7e95c5a49fbf86aeb2c93" } + let(:receiver) { Gitlab::Email::Receiver.new(email_raw) } + let(:markdown) { "![image](uploads/image.png)" } + + def setup_attachment + allow_any_instance_of(Gitlab::Email::AttachmentUploader).to receive(:execute).and_return( + [ + { + url: "uploads/image.png", + alt: "image", + markdown: markdown + } + ] + ) + end +end + +shared_examples :email_shared_examples do + context "when the user could not be found" do + before do + user.destroy + end + + it "raises a UserNotFoundError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotFoundError) + end + end + + context "when the user is not authorized to the project" do + before do + project.update_attribute(:visibility_level, Project::PRIVATE) + end + + it "raises a ProjectNotFound" do + expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + end + end +end diff --git a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb new file mode 100644 index 00000000000..e1153154778 --- /dev/null +++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' +require_relative '../email_shared_blocks' + +describe Gitlab::Email::Handler::CreateIssueHandler, lib: true do + include_context :email_shared_context + it_behaves_like :email_shared_examples + + before do + stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.adventuretime.ooo") + stub_config_setting(host: 'localhost') + end + + let(:email_raw) { fixture_file('emails/valid_new_issue.eml') } + let(:namespace) { create(:namespace, path: 'gitlabhq') } + + let!(:project) { create(:project, :public, namespace: namespace) } + let!(:user) do + create( + :user, + email: 'jake@adventuretime.ooo', + authentication_token: 'auth_token' + ) + end + + context "when everything is fine" do + it "creates a new issue" do + setup_attachment + + expect { receiver.execute }.to change { project.issues.count }.by(1) + issue = project.issues.last + + expect(issue.author).to eq(user) + expect(issue.title).to eq('New Issue by email') + expect(issue.description).to include('reply by email') + expect(issue.description).to include(markdown) + end + + context "when the reply is blank" do + let(:email_raw) { fixture_file("emails/valid_new_issue_empty.eml") } + + it "creates a new issue" do + expect { receiver.execute }.to change { project.issues.count }.by(1) + issue = project.issues.last + + expect(issue.author).to eq(user) + expect(issue.title).to eq('New Issue by email') + expect(issue.description).to eq('') + end + end + end + + context "something is wrong" do + context "when the issue could not be saved" do + before do + allow_any_instance_of(Issue).to receive(:persisted?).and_return(false) + end + + it "raises an InvalidIssueError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidIssueError) + end + end + + context "when we can't find the authentication_token" do + let(:email_raw) { fixture_file("emails/wrong_authentication_token.eml") } + + it "raises an UserNotFoundError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotFoundError) + end + end + + context "when project is private" do + let(:project) { create(:project, :private, namespace: namespace) } + + it "raises a ProjectNotFound if the user is not a member" do + expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + end + end + end +end diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb new file mode 100644 index 00000000000..a2119b0dadf --- /dev/null +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -0,0 +1,116 @@ +require 'spec_helper' +require_relative '../email_shared_blocks' + +describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do + include_context :email_shared_context + it_behaves_like :email_shared_examples + + before do + stub_incoming_email_setting(enabled: true, address: "reply+%{key}@appmail.adventuretime.ooo") + stub_config_setting(host: 'localhost') + end + + let(:email_raw) { fixture_file('emails/valid_reply.eml') } + let(:project) { create(:project, :public) } + let(:noteable) { create(:issue, project: project) } + let(:user) { create(:user) } + + let!(:sent_notification) { SentNotification.record(noteable, user.id, mail_key) } + + context "when the recipient address doesn't include a mail key" do + let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "") } + + it "raises a UnknownIncomingEmail" do + expect { receiver.execute }.to raise_error(Gitlab::Email::UnknownIncomingEmail) + end + end + + context "when no sent notification for the mail key could be found" do + let(:email_raw) { fixture_file('emails/wrong_mail_key.eml') } + + it "raises a SentNotificationNotFoundError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::SentNotificationNotFoundError) + end + end + + context "when the email was auto generated" do + let!(:mail_key) { '636ca428858779856c226bb145ef4fad' } + let!(:email_raw) { fixture_file("emails/auto_reply.eml") } + + it "raises an AutoGeneratedEmailError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::AutoGeneratedEmailError) + end + end + + context "when the noteable could not be found" do + before do + noteable.destroy + end + + it "raises a NoteableNotFoundError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::NoteableNotFoundError) + end + end + + context "when the note could not be saved" do + before do + allow_any_instance_of(Note).to receive(:persisted?).and_return(false) + end + + it "raises an InvalidNoteError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError) + end + end + + context "when the reply is blank" do + let!(:email_raw) { fixture_file("emails/no_content_reply.eml") } + + it "raises an EmptyEmailError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::EmptyEmailError) + end + end + + context "when everything is fine" do + before do + setup_attachment + end + + it "creates a comment" do + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + note = noteable.notes.last + + expect(note.author).to eq(sent_notification.recipient) + expect(note.note).to include("I could not disagree more.") + end + + it "adds all attachments" do + receiver.execute + + note = noteable.notes.last + + expect(note.note).to include(markdown) + end + + context 'when sub-addressing is not supported' do + before do + stub_incoming_email_setting(enabled: true, address: nil) + end + + shared_examples 'an email that contains a mail key' do |header| + it "fetches the mail key from the #{header} header and creates a comment" do + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + note = noteable.notes.last + + expect(note.author).to eq(sent_notification.recipient) + expect(note.note).to include('I could not disagree more.') + end + end + + context 'mail key is in the References header' do + let(:email_raw) { fixture_file('emails/reply_without_subaddressing_and_key_inside_references.eml') } + + it_behaves_like 'an email that contains a mail key', 'References' + end + end + end +end diff --git a/spec/lib/gitlab/email/message/repository_push_spec.rb b/spec/lib/gitlab/email/message/repository_push_spec.rb index c19f33e2224..5b966bddb6a 100644 --- a/spec/lib/gitlab/email/message/repository_push_spec.rb +++ b/spec/lib/gitlab/email/message/repository_push_spec.rb @@ -16,9 +16,12 @@ describe Gitlab::Email::Message::RepositoryPush do { author_id: author.id, ref: 'master', action: :push, compare: compare, send_from_committer_email: true } end - let(:compare) do + let(:raw_compare) do Gitlab::Git::Compare.new(project.repository.raw_repository, - sample_image_commit.id, sample_commit.id) + sample_image_commit.id, sample_commit.id) + end + let(:compare) do + Compare.decorate(raw_compare, project) end describe '#project' do @@ -62,17 +65,17 @@ describe Gitlab::Email::Message::RepositoryPush do describe '#diffs_count' do subject { message.diffs_count } - it { is_expected.to eq compare.diffs.count } + it { is_expected.to eq raw_compare.diffs.size } end describe '#compare' do subject { message.compare } - it { is_expected.to be_an_instance_of Gitlab::Git::Compare } + it { is_expected.to be_an_instance_of Compare } end describe '#compare_timeout' do subject { message.compare_timeout } - it { is_expected.to eq compare.diffs.overflow? } + it { is_expected.to eq raw_compare.diffs.overflow? } end describe '#reverse_compare?' do diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb index 84d2584a791..2a86b427806 100644 --- a/spec/lib/gitlab/email/receiver_spec.rb +++ b/spec/lib/gitlab/email/receiver_spec.rb @@ -1,34 +1,14 @@ -require "spec_helper" +require 'spec_helper' +require_relative 'email_shared_blocks' describe Gitlab::Email::Receiver, lib: true do - before do - stub_incoming_email_setting(enabled: true, address: "reply+%{key}@appmail.adventuretime.ooo") - stub_config_setting(host: 'localhost') - end - - let(:reply_key) { "59d8df8370b7e95c5a49fbf86aeb2c93" } - let(:email_raw) { fixture_file('emails/valid_reply.eml') } - - let(:project) { create(:project, :public) } - let(:noteable) { create(:issue, project: project) } - let(:user) { create(:user) } - let!(:sent_notification) { SentNotification.record(noteable, user.id, reply_key) } - - let(:receiver) { described_class.new(email_raw) } - - context "when the recipient address doesn't include a reply key" do - let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(reply_key, "") } - - it "raises a SentNotificationNotFoundError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::SentNotificationNotFoundError) - end - end + include_context :email_shared_context - context "when no sent notificiation for the reply key could be found" do - let(:email_raw) { fixture_file('emails/wrong_reply_key.eml') } + context "when we cannot find a capable handler" do + let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "!!!") } - it "raises a SentNotificationNotFoundError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::SentNotificationNotFoundError) + it "raises a UnknownIncomingEmail" do + expect { receiver.execute }.to raise_error(Gitlab::Email::UnknownIncomingEmail) end end @@ -36,128 +16,7 @@ describe Gitlab::Email::Receiver, lib: true do let(:email_raw) { "" } it "raises an EmptyEmailError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::EmptyEmailError) - end - end - - context "when the email was auto generated" do - let!(:reply_key) { '636ca428858779856c226bb145ef4fad' } - let!(:email_raw) { fixture_file("emails/auto_reply.eml") } - - it "raises an AutoGeneratedEmailError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::AutoGeneratedEmailError) - end - end - - context "when the user could not be found" do - before do - user.destroy - end - - it "raises a UserNotFoundError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::UserNotFoundError) - end - end - - context "when the user has been blocked" do - before do - user.block - end - - it "raises a UserBlockedError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::UserBlockedError) - end - end - - context "when the user is not authorized to create a note" do - before do - project.update_attribute(:visibility_level, Project::PRIVATE) - end - - it "raises a UserNotAuthorizedError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::UserNotAuthorizedError) - end - end - - context "when the noteable could not be found" do - before do - noteable.destroy - end - - it "raises a NoteableNotFoundError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::NoteableNotFoundError) - end - end - - context "when the reply is blank" do - let!(:email_raw) { fixture_file("emails/no_content_reply.eml") } - - it "raises an EmptyEmailError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::EmptyEmailError) - end - end - - context "when the note could not be saved" do - before do - allow_any_instance_of(Note).to receive(:persisted?).and_return(false) - end - - it "raises an InvalidNoteError" do - expect { receiver.execute }.to raise_error(Gitlab::Email::Receiver::InvalidNoteError) - end - end - - context "when everything is fine" do - let(:markdown) { "![image](uploads/image.png)" } - - before do - allow_any_instance_of(Gitlab::Email::AttachmentUploader).to receive(:execute).and_return( - [ - { - url: "uploads/image.png", - alt: "image", - markdown: markdown - } - ] - ) - end - - it "creates a comment" do - expect { receiver.execute }.to change { noteable.notes.count }.by(1) - note = noteable.notes.last - - expect(note.author).to eq(sent_notification.recipient) - expect(note.note).to include("I could not disagree more.") - end - - it "adds all attachments" do - receiver.execute - - note = noteable.notes.last - - expect(note.note).to include(markdown) - end - - context 'when sub-addressing is not supported' do - before do - stub_incoming_email_setting(enabled: true, address: nil) - end - - shared_examples 'an email that contains a reply key' do |header| - it "fetches the reply key from the #{header} header and creates a comment" do - expect { receiver.execute }.to change { noteable.notes.count }.by(1) - note = noteable.notes.last - - expect(note.author).to eq(sent_notification.recipient) - expect(note.note).to include('I could not disagree more.') - end - end - - context 'reply key is in the References header' do - let(:email_raw) { fixture_file('emails/reply_without_subaddressing_and_key_inside_references.eml') } - - it_behaves_like 'an email that contains a reply key', 'References' - end + expect { receiver.execute }.to raise_error(Gitlab::Email::EmptyEmailError) end end end diff --git a/spec/lib/gitlab/git/hook_spec.rb b/spec/lib/gitlab/git/hook_spec.rb index a15aa173fbd..d1f947b6850 100644 --- a/spec/lib/gitlab/git/hook_spec.rb +++ b/spec/lib/gitlab/git/hook_spec.rb @@ -25,7 +25,6 @@ describe Gitlab::Git::Hook, lib: true do end ['pre-receive', 'post-receive', 'update'].each do |hook_name| - context "when triggering a #{hook_name} hook" do context "when the hook is successful" do it "returns success with no errors" do diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index ae064a878b0..f12c9a370f7 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -19,11 +19,11 @@ describe Gitlab::GitAccess, lib: true do end it 'blocks ssh git push' do - expect(@acc.check('git-receive-pack').allowed?).to be_falsey + expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey end it 'blocks ssh git pull' do - expect(@acc.check('git-upload-pack').allowed?).to be_falsey + expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey end end @@ -34,17 +34,17 @@ describe Gitlab::GitAccess, lib: true do end it 'blocks http push' do - expect(@acc.check('git-receive-pack').allowed?).to be_falsey + expect(@acc.check('git-receive-pack', '_any').allowed?).to be_falsey end it 'blocks http git pull' do - expect(@acc.check('git-upload-pack').allowed?).to be_falsey + expect(@acc.check('git-upload-pack', '_any').allowed?).to be_falsey end end end describe 'download_access_check' do - subject { access.check('git-upload-pack') } + subject { access.check('git-upload-pack', '_any') } describe 'master permissions' do before { project.team << [user, :master] } @@ -151,7 +151,13 @@ describe Gitlab::GitAccess, lib: true do def self.run_permission_checks(permissions_matrix) permissions_matrix.keys.each do |role| describe "#{role} access" do - before { project.team << [user, role] } + before do + if role == :admin + user.update_attribute(:admin, true) + else + project.team << [user, role] + end + end permissions_matrix[role].each do |action, allowed| context action do @@ -165,6 +171,17 @@ describe Gitlab::GitAccess, lib: true do end permissions_matrix = { + admin: { + push_new_branch: true, + push_master: true, + push_protected_branch: true, + push_remove_protected_branch: false, + push_tag: true, + push_new_tag: true, + push_all: true, + merge_into_protected_branch: true + }, + master: { push_new_branch: true, push_master: true, @@ -217,19 +234,20 @@ describe Gitlab::GitAccess, lib: true do run_permission_checks(permissions_matrix) end - context "when 'developers can push' is turned on for the #{protected_branch_type} protected branch" do - before { create(:protected_branch, name: protected_branch_name, developers_can_push: true, project: project) } + context "when developers are allowed to push into the #{protected_branch_type} protected branch" do + before { create(:protected_branch, :developers_can_push, name: protected_branch_name, project: project) } run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true })) end - context "when 'developers can merge' is turned on for the #{protected_branch_type} protected branch" do - before { create(:protected_branch, name: protected_branch_name, developers_can_merge: true, project: project) } + context "developers are allowed to merge into the #{protected_branch_type} protected branch" do + before { create(:protected_branch, :developers_can_merge, name: protected_branch_name, project: project) } context "when a merge request exists for the given source/target branch" do context "when the merge request is in progress" do before do - create(:merge_request, source_project: project, source_branch: unprotected_branch, target_branch: 'feature', state: 'locked', in_progress_merge_commit_sha: merge_into_protected_branch) + create(:merge_request, source_project: project, source_branch: unprotected_branch, target_branch: 'feature', + state: 'locked', in_progress_merge_commit_sha: merge_into_protected_branch) end run_permission_checks(permissions_matrix.deep_merge(developer: { merge_into_protected_branch: true })) @@ -242,51 +260,59 @@ describe Gitlab::GitAccess, lib: true do run_permission_checks(permissions_matrix.deep_merge(developer: { merge_into_protected_branch: false })) end - end - context "when a merge request does not exist for the given source/target branch" do - run_permission_checks(permissions_matrix.deep_merge(developer: { merge_into_protected_branch: false })) + context "when a merge request does not exist for the given source/target branch" do + run_permission_checks(permissions_matrix.deep_merge(developer: { merge_into_protected_branch: false })) + end end end - context "when 'developers can merge' and 'developers can push' are turned on for the #{protected_branch_type} protected branch" do - before { create(:protected_branch, name: protected_branch_name, developers_can_merge: true, developers_can_push: true, project: project) } + context "when developers are allowed to push and merge into the #{protected_branch_type} protected branch" do + before { create(:protected_branch, :developers_can_merge, :developers_can_push, name: protected_branch_name, project: project) } run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true })) end + + context "when no one is allowed to push to the #{protected_branch_name} protected branch" do + before { create(:protected_branch, :no_one_can_push, name: protected_branch_name, project: project) } + + run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false }, + master: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false }, + admin: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false })) + end end + end - describe 'deploy key permissions' do - let(:key) { create(:deploy_key) } - let(:actor) { key } + describe 'deploy key permissions' do + let(:key) { create(:deploy_key) } + let(:actor) { key } - context 'push code' do - subject { access.check('git-receive-pack') } + context 'push code' do + subject { access.check('git-receive-pack', '_any') } - context 'when project is authorized' do - before { key.projects << project } + context 'when project is authorized' do + before { key.projects << project } - it { expect(subject).not_to be_allowed } - end + it { expect(subject).not_to be_allowed } + end - context 'when unauthorized' do - context 'to public project' do - let(:project) { create(:project, :public) } + context 'when unauthorized' do + context 'to public project' do + let(:project) { create(:project, :public) } - it { expect(subject).not_to be_allowed } - end + it { expect(subject).not_to be_allowed } + end - context 'to internal project' do - let(:project) { create(:project, :internal) } + context 'to internal project' do + let(:project) { create(:project, :internal) } - it { expect(subject).not_to be_allowed } - end + it { expect(subject).not_to be_allowed } + end - context 'to private project' do - let(:project) { create(:project, :internal) } + context 'to private project' do + let(:project) { create(:project, :internal) } - it { expect(subject).not_to be_allowed } - end + it { expect(subject).not_to be_allowed } end end end diff --git a/spec/lib/gitlab/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb index 364532e94e3..fc021416d92 100644 --- a/spec/lib/gitlab/highlight_spec.rb +++ b/spec/lib/gitlab/highlight_spec.rb @@ -17,6 +17,18 @@ describe Gitlab::Highlight, lib: true do expect(lines[21]).to eq(%Q{<span id="LC22" class="line"> <span class="k">unless</span> <span class="no">File</span><span class="p">.</span><span class="nf">directory?</span><span class="p">(</span><span class="n">path</span><span class="p">)</span></span>\n}) expect(lines[26]).to eq(%Q{<span id="LC27" class="line"> <span class="vi">@cmd_status</span> <span class="o">=</span> <span class="mi">0</span></span>\n}) end + + describe 'with CRLF' do + let(:branch) { 'crlf-diff' } + let(:blob) { repository.blob_at_branch(branch, path) } + let(:lines) do + Gitlab::Highlight.highlight_lines(project.repository, 'crlf-diff', 'files/whitespace') + end + + it 'strips extra LFs' do + expect(lines[0]).to eq("<span id=\"LC1\" class=\"line\">test </span>") + end + end end describe 'custom highlighting from .gitattributes' do diff --git a/spec/lib/gitlab/import_export/members_mapper_spec.rb b/spec/lib/gitlab/import_export/members_mapper_spec.rb index 6d5aa0d04a2..770e8b0c2f4 100644 --- a/spec/lib/gitlab/import_export/members_mapper_spec.rb +++ b/spec/lib/gitlab/import_export/members_mapper_spec.rb @@ -26,6 +26,20 @@ describe Gitlab::ImportExport::MembersMapper, services: true do "email" => user2.email, "username" => user2.username } + }, + { + "id" => 3, + "access_level" => 40, + "source_id" => 14, + "source_type" => "Project", + "user_id" => nil, + "notification_level" => 3, + "created_at" => "2016-03-11T10:21:44.822Z", + "updated_at" => "2016-03-11T10:21:44.822Z", + "created_by_id" => 1, + "invite_email" => 'invite@test.com', + "invite_token" => 'token', + "invite_accepted_at" => nil }] end @@ -47,5 +61,11 @@ describe Gitlab::ImportExport::MembersMapper, services: true do expect(members_mapper.missing_author_ids.first).to eq(-1) end + + it 'has invited members with no user' do + members_mapper.map + + expect(ProjectMember.find_by_invite_email('invite@test.com')).not_to be_nil + end end end diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index b1a5d72c624..b5550ca1963 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -18,7 +18,6 @@ "position": 0, "branch_name": null, "description": "Aliquam enim illo et possimus.", - "milestone_id": 18, "state": "opened", "iid": 10, "updated_by_id": null, @@ -27,6 +26,52 @@ "due_date": null, "moved_to_id": null, "test_ee_field": "test", + "milestone": { + "id": 1, + "title": "v0.0", + "project_id": 8, + "description": "test milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "events": [ + { + "id": 487, + "target_type": "Milestone", + "target_id": 1, + "title": null, + "data": null, + "project_id": 46, + "created_at": "2016-06-14T15:02:04.418Z", + "updated_at": "2016-06-14T15:02:04.418Z", + "action": 1, + "author_id": 18 + } + ] + }, + "label_links": [ + { + "id": 2, + "label_id": 2, + "target_id": 3, + "target_type": "Issue", + "created_at": "2016-07-22T08:57:02.840Z", + "updated_at": "2016-07-22T08:57:02.840Z", + "label": { + "id": 2, + "title": "test2", + "color": "#428bca", + "project_id": 8, + "created_at": "2016-07-22T08:55:44.161Z", + "updated_at": "2016-07-22T08:55:44.161Z", + "template": false, + "description": "", + "priority": null + } + } + ], "notes": [ { "id": 351, @@ -233,7 +278,6 @@ "position": 0, "branch_name": null, "description": "Voluptate vel reprehenderit facilis omnis voluptas magnam tenetur.", - "milestone_id": 16, "state": "opened", "iid": 9, "updated_by_id": null, @@ -447,7 +491,6 @@ "position": 0, "branch_name": null, "description": "Ea recusandae neque autem tempora.", - "milestone_id": 16, "state": "closed", "iid": 8, "updated_by_id": null, @@ -661,7 +704,6 @@ "position": 0, "branch_name": null, "description": "Maiores architecto quos in dolorem.", - "milestone_id": 17, "state": "opened", "iid": 7, "updated_by_id": null, @@ -875,7 +917,6 @@ "position": 0, "branch_name": null, "description": "Ut aut ut et tenetur velit aut id modi.", - "milestone_id": 16, "state": "opened", "iid": 6, "updated_by_id": null, @@ -1089,7 +1130,6 @@ "position": 0, "branch_name": null, "description": "Dicta nisi nihil non ipsa velit.", - "milestone_id": 20, "state": "closed", "iid": 5, "updated_by_id": null, @@ -1303,7 +1343,6 @@ "position": 0, "branch_name": null, "description": "Ut et explicabo vel voluptatem consequuntur ut sed.", - "milestone_id": 19, "state": "closed", "iid": 4, "updated_by_id": null, @@ -1517,7 +1556,6 @@ "position": 0, "branch_name": null, "description": "Non asperiores velit accusantium voluptate.", - "milestone_id": 18, "state": "closed", "iid": 3, "updated_by_id": null, @@ -1731,7 +1769,6 @@ "position": 0, "branch_name": null, "description": "Molestiae corporis magnam et fugit aliquid nulla quia.", - "milestone_id": 17, "state": "closed", "iid": 2, "updated_by_id": null, @@ -1945,7 +1982,6 @@ "position": 0, "branch_name": null, "description": "Quod ad architecto qui est sed quia.", - "milestone_id": 20, "state": "closed", "iid": 1, "updated_by_id": null, @@ -2259,117 +2295,6 @@ "author_id": 25 } ] - }, - { - "id": 18, - "title": "v2.0", - "project_id": 5, - "description": "Error dolorem rerum aut nulla.", - "due_date": null, - "created_at": "2016-06-14T15:02:04.576Z", - "updated_at": "2016-06-14T15:02:04.576Z", - "state": "active", - "iid": 3, - "events": [ - { - "id": 242, - "target_type": "Milestone", - "target_id": 18, - "title": null, - "data": null, - "project_id": 36, - "created_at": "2016-06-14T15:02:04.579Z", - "updated_at": "2016-06-14T15:02:04.579Z", - "action": 1, - "author_id": 1 - }, - { - "id": 58, - "target_type": "Milestone", - "target_id": 18, - "title": null, - "data": null, - "project_id": 5, - "created_at": "2016-06-14T15:02:04.579Z", - "updated_at": "2016-06-14T15:02:04.579Z", - "action": 1, - "author_id": 22 - } - ] - }, - { - "id": 17, - "title": "v1.0", - "project_id": 5, - "description": "Molestiae perspiciatis voluptates doloremque commodi veniam consequatur.", - "due_date": null, - "created_at": "2016-06-14T15:02:04.569Z", - "updated_at": "2016-06-14T15:02:04.569Z", - "state": "active", - "iid": 2, - "events": [ - { - "id": 243, - "target_type": "Milestone", - "target_id": 17, - "title": null, - "data": null, - "project_id": 36, - "created_at": "2016-06-14T15:02:04.570Z", - "updated_at": "2016-06-14T15:02:04.570Z", - "action": 1, - "author_id": 1 - }, - { - "id": 57, - "target_type": "Milestone", - "target_id": 17, - "title": null, - "data": null, - "project_id": 5, - "created_at": "2016-06-14T15:02:04.570Z", - "updated_at": "2016-06-14T15:02:04.570Z", - "action": 1, - "author_id": 20 - } - ] - }, - { - "id": 16, - "title": "v0.0", - "project_id": 5, - "description": "Velit numquam et sed sit.", - "due_date": null, - "created_at": "2016-06-14T15:02:04.561Z", - "updated_at": "2016-06-14T15:02:04.561Z", - "state": "closed", - "iid": 1, - "events": [ - { - "id": 244, - "target_type": "Milestone", - "target_id": 16, - "title": null, - "data": null, - "project_id": 36, - "created_at": "2016-06-14T15:02:04.563Z", - "updated_at": "2016-06-14T15:02:04.563Z", - "action": 1, - "author_id": 26 - }, - { - "id": 56, - "target_type": "Milestone", - "target_id": 16, - "title": null, - "data": null, - "project_id": 5, - "created_at": "2016-06-14T15:02:04.563Z", - "updated_at": "2016-06-14T15:02:04.563Z", - "action": 1, - "author_id": 26 - } - ] } ], "snippets": [ @@ -2471,7 +2396,6 @@ "title": "Cannot be automatically merged", "created_at": "2016-06-14T15:02:36.568Z", "updated_at": "2016-06-14T15:02:56.815Z", - "milestone_id": null, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, @@ -2909,7 +2833,6 @@ "title": "Can be automatically merged", "created_at": "2016-06-14T15:02:36.418Z", "updated_at": "2016-06-14T15:02:57.013Z", - "milestone_id": null, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, @@ -3194,7 +3117,6 @@ "title": "Qui accusantium et inventore facilis doloribus occaecati officiis.", "created_at": "2016-06-14T15:02:25.168Z", "updated_at": "2016-06-14T15:02:59.521Z", - "milestone_id": 17, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, @@ -3479,7 +3401,6 @@ "title": "In voluptas aut sequi voluptatem ullam vel corporis illum consequatur.", "created_at": "2016-06-14T15:02:24.760Z", "updated_at": "2016-06-14T15:02:59.749Z", - "milestone_id": 20, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, @@ -4170,7 +4091,6 @@ "title": "Voluptates consequatur eius nemo amet libero animi illum delectus tempore.", "created_at": "2016-06-14T15:02:24.415Z", "updated_at": "2016-06-14T15:02:59.958Z", - "milestone_id": 17, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, @@ -4719,7 +4639,6 @@ "title": "In a rerum harum nihil accusamus aut quia nobis non.", "created_at": "2016-06-14T15:02:24.000Z", "updated_at": "2016-06-14T15:03:00.225Z", - "milestone_id": 19, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, @@ -5219,7 +5138,6 @@ "title": "Corporis provident similique perspiciatis dolores eos animi.", "created_at": "2016-06-14T15:02:23.767Z", "updated_at": "2016-06-14T15:03:00.475Z", - "milestone_id": 18, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, @@ -5480,7 +5398,6 @@ "title": "Eligendi reprehenderit doloribus quia et sit id.", "created_at": "2016-06-14T15:02:23.014Z", "updated_at": "2016-06-14T15:03:00.685Z", - "milestone_id": 20, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, @@ -6171,7 +6088,6 @@ "title": "Et ipsam voluptas velit sequi illum ut.", "created_at": "2016-06-14T15:02:22.825Z", "updated_at": "2016-06-14T15:03:00.904Z", - "milestone_id": 16, "state": "opened", "merge_status": "unchecked", "target_project_id": 5, 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 6ae20c943b1..5fdd4d5f25f 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -2,7 +2,6 @@ require 'spec_helper' describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do describe 'restore project tree' do - let(:user) { create(:user) } let(:namespace) { create(:namespace, owner: user) } let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: "", project_path: 'path') } @@ -60,6 +59,18 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do expect { restored_project_json }.to change(MergeRequestDiff.where.not(st_diffs: nil), :count).by(9) end + + it 'has labels associated to label links, associated to issues' do + restored_project_json + + expect(Label.first.label_links.first.target).not_to be_nil + end + + it 'has milestones associated to issues' do + restored_project_json + + expect(Milestone.find_by_description('test milestone').issues).not_to be_empty + end end end 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 057ef6e76a0..3a86a4ce07c 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -31,10 +31,6 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do expect(saved_project_json).to include({ "visibility_level" => 20 }) end - it 'has events' do - expect(saved_project_json['milestones'].first['events']).not_to be_empty - end - it 'has milestones' do expect(saved_project_json['milestones']).not_to be_empty end @@ -43,8 +39,12 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do expect(saved_project_json['merge_requests']).not_to be_empty end - it 'has labels' do - expect(saved_project_json['labels']).not_to be_empty + it 'has merge request\'s milestones' do + expect(saved_project_json['merge_requests'].first['milestone']).not_to be_empty + end + + it 'has events' do + expect(saved_project_json['merge_requests'].first['milestone']['events']).not_to be_empty end it 'has snippets' do @@ -103,6 +103,14 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do expect(saved_project_json['pipelines'].first['notes']).not_to be_empty end + it 'has labels with no associations' do + expect(saved_project_json['labels']).not_to be_empty + end + + it 'has labels associated to records' do + expect(saved_project_json['issues'].first['label_links'].first['label']).not_to be_empty + end + it 'does not complain about non UTF-8 characters in MR diffs' do ActiveRecord::Base.connection.execute("UPDATE merge_request_diffs SET st_diffs = '---\n- :diff: !binary |-\n LS0tIC9kZXYvbnVsbAorKysgYi9pbWFnZXMvbnVjb3IucGRmCkBAIC0wLDAg\n KzEsMTY3OSBAQAorJVBERi0xLjUNJeLjz9MNCisxIDAgb2JqDTw8L01ldGFk\n YXR'") @@ -113,19 +121,19 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do def setup_project issue = create(:issue, assignee: user) - label = create(:label) snippet = create(:project_snippet) release = create(:release) project = create(:project, :public, issues: [issue], - labels: [label], snippets: [snippet], releases: [release] ) - - merge_request = create(:merge_request, source_project: project) + label = create(:label, project: project) + create(:label_link, label: label, target: issue) + milestone = create(:milestone, project: project) + merge_request = create(:merge_request, source_project: project, milestone: milestone) commit_status = create(:commit_status, project: project) ci_pipeline = create(:ci_pipeline, @@ -135,7 +143,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do statuses: [commit_status]) create(:ci_build, pipeline: ci_pipeline, project: project) - milestone = create(:milestone, project: project) + create(:milestone, project: project) create(:note, noteable: issue, project: project) create(:note, noteable: merge_request, project: project) create(:note, noteable: snippet, project: project) diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb new file mode 100644 index 00000000000..90c6d1c67f6 --- /dev/null +++ b/spec/lib/gitlab/import_export/version_checker_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe Gitlab::ImportExport::VersionChecker, services: true do + describe 'bundle a project Git repo' do + let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: '') } + let(:version) { Gitlab::ImportExport.version } + + before do + allow(File).to receive(:open).and_return(version) + end + + it 'returns true if Import/Export have the same version' do + expect(described_class.check!(shared: shared)).to be true + end + + context 'newer version' do + let(:version) { '900.0'} + + it 'returns false if export version is newer' do + expect(described_class.check!(shared: shared)).to be false + end + + it 'shows the correct error message' do + described_class.check!(shared: shared) + + expect(shared.errors.first).to eq("Import version mismatch: Required <= #{Gitlab::ImportExport.version} but was #{version}") + end + end + end +end diff --git a/spec/lib/gitlab/incoming_email_spec.rb b/spec/lib/gitlab/incoming_email_spec.rb index afb3e26f8fb..1dcf2c0668b 100644 --- a/spec/lib/gitlab/incoming_email_spec.rb +++ b/spec/lib/gitlab/incoming_email_spec.rb @@ -43,9 +43,9 @@ describe Gitlab::IncomingEmail, lib: true do end end - context 'self.key_from_fallback_reply_message_id' do + context 'self.key_from_fallback_message_id' do it 'returns reply key' do - expect(described_class.key_from_fallback_reply_message_id('reply-key@localhost')).to eq('key') + expect(described_class.key_from_fallback_message_id('reply-key@localhost')).to eq('key') end end end diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb index 8809b7e3f12..d88bcae41fb 100644 --- a/spec/lib/gitlab/metrics/instrumentation_spec.rb +++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb @@ -39,6 +39,12 @@ describe Gitlab::Metrics::Instrumentation do allow(@dummy).to receive(:name).and_return('Dummy') end + describe '.series' do + it 'returns a String' do + expect(described_class.series).to be_an_instance_of(String) + end + end + describe '.configure' do it 'yields self' do described_class.configure do |c| @@ -78,8 +84,7 @@ describe Gitlab::Metrics::Instrumentation do allow(described_class).to receive(:transaction). and_return(transaction) - expect(transaction).to receive(:measure_method). - with('Dummy.foo') + expect_any_instance_of(Gitlab::Metrics::MethodCall).to receive(:measure) @dummy.foo end @@ -157,8 +162,7 @@ describe Gitlab::Metrics::Instrumentation do allow(described_class).to receive(:transaction). and_return(transaction) - expect(transaction).to receive(:measure_method). - with('Dummy#bar') + expect_any_instance_of(Gitlab::Metrics::MethodCall).to receive(:measure) @dummy.new.bar end diff --git a/spec/lib/gitlab/metrics/system_spec.rb b/spec/lib/gitlab/metrics/system_spec.rb index cf0e282c2fb..9e2ea89a712 100644 --- a/spec/lib/gitlab/metrics/system_spec.rb +++ b/spec/lib/gitlab/metrics/system_spec.rb @@ -28,20 +28,20 @@ describe Gitlab::Metrics::System do end describe '.cpu_time' do - it 'returns a Float' do - expect(described_class.cpu_time).to be_an_instance_of(Float) + it 'returns a Fixnum' do + expect(described_class.cpu_time).to be_an_instance_of(Fixnum) end end describe '.real_time' do - it 'returns a Float' do - expect(described_class.real_time).to be_an_instance_of(Float) + it 'returns a Fixnum' do + expect(described_class.real_time).to be_an_instance_of(Fixnum) end end describe '.monotonic_time' do - it 'returns a Float' do - expect(described_class.monotonic_time).to be_an_instance_of(Float) + it 'returns a Fixnum' do + expect(described_class.monotonic_time).to be_an_instance_of(Fixnum) end end end diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb index 3b1c67a2147..f1a191d9410 100644 --- a/spec/lib/gitlab/metrics/transaction_spec.rb +++ b/spec/lib/gitlab/metrics/transaction_spec.rb @@ -46,19 +46,11 @@ describe Gitlab::Metrics::Transaction do end end - describe '#measure_method' do - it 'adds a new method if it does not exist already' do - transaction.measure_method('Foo#bar') { 'foo' } + describe '#method_call_for' do + it 'returns a MethodCall' do + method = transaction.method_call_for('Foo#bar') - expect(transaction.methods['Foo#bar']). - to be_an_instance_of(Gitlab::Metrics::MethodCall) - end - - it 'adds timings to an existing method call' do - transaction.measure_method('Foo#bar') { 'foo' } - transaction.measure_method('Foo#bar') { 'foo' } - - expect(transaction.methods['Foo#bar'].call_count).to eq(2) + expect(method).to be_an_instance_of(Gitlab::Metrics::MethodCall) end end diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb index 96f7eabbca6..84f9475a0f8 100644 --- a/spec/lib/gitlab/metrics_spec.rb +++ b/spec/lib/gitlab/metrics_spec.rb @@ -147,4 +147,10 @@ describe Gitlab::Metrics do end end end + + describe '#series_prefix' do + it 'returns a String' do + expect(described_class.series_prefix).to be_an_instance_of(String) + end + end end diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb index aa9ec243498..d3c3b800b94 100644 --- a/spec/lib/gitlab/user_access_spec.rb +++ b/spec/lib/gitlab/user_access_spec.rb @@ -9,80 +9,130 @@ describe Gitlab::UserAccess, lib: true do describe 'push to none protected branch' do it 'returns true if user is a master' do project.team << [user, :master] + expect(access.can_push_to_branch?('random_branch')).to be_truthy end it 'returns true if user is a developer' do project.team << [user, :developer] + expect(access.can_push_to_branch?('random_branch')).to be_truthy end it 'returns false if user is a reporter' do project.team << [user, :reporter] + expect(access.can_push_to_branch?('random_branch')).to be_falsey end end + describe 'push to empty project' do + let(:empty_project) { create(:project_empty_repo) } + let(:project_access) { Gitlab::UserAccess.new(user, project: empty_project) } + + it 'returns true if user is master' do + empty_project.team << [user, :master] + + expect(project_access.can_push_to_branch?('master')).to be_truthy + end + + it 'returns false if user is developer and project is fully protected' do + empty_project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL) + + expect(project_access.can_push_to_branch?('master')).to be_falsey + end + + it 'returns false if user is developer and it is not allowed to push new commits but can merge into branch' do + empty_project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE) + + expect(project_access.can_push_to_branch?('master')).to be_falsey + end + + it 'returns true if user is developer and project is unprotected' do + empty_project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) + + expect(project_access.can_push_to_branch?('master')).to be_truthy + end + + it 'returns true if user is developer and project grants developers permission' do + empty_project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) + + expect(project_access.can_push_to_branch?('master')).to be_truthy + end + end + describe 'push to protected branch' do let(:branch) { create :protected_branch, project: project } it 'returns true if user is a master' do project.team << [user, :master] + expect(access.can_push_to_branch?(branch.name)).to be_truthy end it 'returns false if user is a developer' do project.team << [user, :developer] + expect(access.can_push_to_branch?(branch.name)).to be_falsey end it 'returns false if user is a reporter' do project.team << [user, :reporter] + expect(access.can_push_to_branch?(branch.name)).to be_falsey end end describe 'push to protected branch if allowed for developers' do before do - @branch = create :protected_branch, project: project, developers_can_push: true + @branch = create :protected_branch, :developers_can_push, project: project end it 'returns true if user is a master' do project.team << [user, :master] + expect(access.can_push_to_branch?(@branch.name)).to be_truthy end it 'returns true if user is a developer' do project.team << [user, :developer] + expect(access.can_push_to_branch?(@branch.name)).to be_truthy end it 'returns false if user is a reporter' do project.team << [user, :reporter] + expect(access.can_push_to_branch?(@branch.name)).to be_falsey end end describe 'merge to protected branch if allowed for developers' do before do - @branch = create :protected_branch, project: project, developers_can_merge: true + @branch = create :protected_branch, :developers_can_merge, project: project end it 'returns true if user is a master' do project.team << [user, :master] + expect(access.can_merge_to_branch?(@branch.name)).to be_truthy end it 'returns true if user is a developer' do project.team << [user, :developer] + expect(access.can_merge_to_branch?(@branch.name)).to be_truthy end it 'returns false if user is a reporter' do project.team << [user, :reporter] + expect(access.can_merge_to_branch?(@branch.name)).to be_falsey end end - end end diff --git a/spec/lib/repository_cache_spec.rb b/spec/lib/repository_cache_spec.rb index 63b5292b098..f227926f39c 100644 --- a/spec/lib/repository_cache_spec.rb +++ b/spec/lib/repository_cache_spec.rb @@ -1,33 +1,34 @@ -require_relative '../../lib/repository_cache' +require 'spec_helper' describe RepositoryCache, lib: true do + let(:project) { create(:project) } let(:backend) { double('backend').as_null_object } - let(:cache) { RepositoryCache.new('example', backend) } + let(:cache) { RepositoryCache.new('example', project.id, backend) } describe '#cache_key' do it 'includes the namespace' do - expect(cache.cache_key(:foo)).to eq 'foo:example' + expect(cache.cache_key(:foo)).to eq "foo:example:#{project.id}" end end describe '#expire' do it 'expires the given key from the cache' do cache.expire(:foo) - expect(backend).to have_received(:delete).with('foo:example') + expect(backend).to have_received(:delete).with("foo:example:#{project.id}") end end describe '#fetch' do it 'fetches the given key from the cache' do cache.fetch(:bar) - expect(backend).to have_received(:fetch).with('bar:example') + expect(backend).to have_received(:fetch).with("bar:example:#{project.id}") end it 'accepts a block' do p = -> {} cache.fetch(:baz, &p) - expect(backend).to have_received(:fetch).with('baz:example', &p) + expect(backend).to have_received(:fetch).with("baz:example:#{project.id}", &p) end end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 3685b2b17b5..e2866ef160c 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -944,8 +944,9 @@ describe Notify do describe 'email on push with multiple commits' do let(:example_site_path) { root_path } let(:user) { create(:user) } - let(:compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_image_commit.id, sample_commit.id) } - let(:commits) { Commit.decorate(compare.commits, nil) } + let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_image_commit.id, sample_commit.id) } + let(:compare) { Compare.decorate(raw_compare, project) } + let(:commits) { compare.commits } let(:diff_path) { namespace_project_compare_path(project.namespace, project, from: Commit.new(compare.base, project), to: Commit.new(compare.head, project)) } 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) } @@ -1046,8 +1047,9 @@ describe Notify do describe 'email on push with a single commit' do let(:example_site_path) { root_path } let(:user) { create(:user) } - let(:compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_commit.parent_id, sample_commit.id) } - let(:commits) { Commit.decorate(compare.commits, nil) } + let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_commit.parent_id, sample_commit.id) } + let(:compare) { Compare.decorate(raw_compare, project) } + let(:commits) { compare.commits } 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) } diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 1acb5846fcf..853f6943cef 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -1,6 +1,62 @@ require 'spec_helper' describe Ability, lib: true do + describe '.can_edit_note?' do + let(:project) { create(:empty_project) } + let!(:note) { create(:note_on_issue, project: project) } + + context 'using an anonymous user' do + it 'returns false' do + expect(described_class.can_edit_note?(nil, note)).to be_falsy + end + end + + context 'using a system note' do + it 'returns false' do + system_note = create(:note, system: true) + user = create(:user) + + expect(described_class.can_edit_note?(user, system_note)).to be_falsy + end + end + + context 'using users with different access levels' do + let(:user) { create(:user) } + + it 'returns true for the author' do + expect(described_class.can_edit_note?(note.author, note)).to be_truthy + end + + it 'returns false for a guest user' do + project.team << [user, :guest] + + expect(described_class.can_edit_note?(user, note)).to be_falsy + end + + it 'returns false for a developer' do + project.team << [user, :developer] + + expect(described_class.can_edit_note?(user, note)).to be_falsy + end + + it 'returns true for a master' do + project.team << [user, :master] + + expect(described_class.can_edit_note?(user, note)).to be_truthy + end + + it 'returns true for a group owner' do + group = create(:group) + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::MASTER) + group.add_owner(user) + + expect(described_class.can_edit_note?(user, note)).to be_truthy + end + end + end + describe '.users_that_can_read_project' do context 'using a public project' do it 'returns all the users' do @@ -114,4 +170,52 @@ describe Ability, lib: true do end end end + + describe '.issues_readable_by_user' do + context 'with an admin user' do + it 'returns all given issues' do + user = build(:user, admin: true) + issue = build(:issue) + + expect(described_class.issues_readable_by_user([issue], user)). + to eq([issue]) + end + end + + context 'with a regular user' do + it 'returns the issues readable by the user' do + user = build(:user) + issue = build(:issue) + + expect(issue).to receive(:readable_by?).with(user).and_return(true) + + expect(described_class.issues_readable_by_user([issue], user)). + to eq([issue]) + end + + it 'returns an empty Array when no issues are readable' do + user = build(:user) + issue = build(:issue) + + expect(issue).to receive(:readable_by?).with(user).and_return(false) + + expect(described_class.issues_readable_by_user([issue], user)).to eq([]) + end + end + + context 'without a regular user' do + it 'returns issues that are publicly visible' do + hidden_issue = build(:issue) + visible_issue = build(:issue) + + expect(hidden_issue).to receive(:publicly_visible?).and_return(false) + expect(visible_issue).to receive(:publicly_visible?).and_return(true) + + issues = described_class. + issues_readable_by_user([hidden_issue, visible_issue]) + + expect(issues).to eq([visible_issue]) + end + end + end end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 78e95c8fac5..1e5d6a34f83 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -33,6 +33,22 @@ describe Blob do end end + describe '#video?' do + it 'is falsey with image extension' do + git_blob = Gitlab::Git::Blob.new(name: 'image.png') + + expect(described_class.decorate(git_blob)).not_to be_video + end + + UploaderHelper::VIDEO_EXT.each do |ext| + it "is truthy when extension is .#{ext}" do + git_blob = Gitlab::Git::Blob.new(name: "video.#{ext}") + + expect(described_class.decorate(git_blob)).to be_video + end + end + end + describe '#to_partial_path' do def stubbed_blob(overrides = {}) overrides.reverse_merge!( diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 978ad9c52d5..dc88697199b 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -259,7 +259,7 @@ describe Ci::Build, models: true do let(:trigger) { create(:ci_trigger, project: project) } let(:trigger_request) { create(:ci_trigger_request_with_variables, pipeline: pipeline, trigger: trigger) } let(:user_trigger_variable) do - { key: :TRIGGER_KEY, value: 'TRIGGER_VALUE', public: false } + { key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1', public: false } end let(:predefined_trigger_variable) do { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index ba02d5fe977..d3e6a6648cc 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -13,6 +13,26 @@ describe Commit, models: true do it { is_expected.to include_module(StaticModel) } end + describe '#author' do + it 'looks up the author in a case-insensitive way' do + user = create(:user, email: commit.author_email.upcase) + expect(commit.author).to eq(user) + end + + it 'caches the author' do + user = create(:user, email: commit.author_email) + expect(RequestStore).to receive(:active?).twice.and_return(true) + expect_any_instance_of(Commit).to receive(:find_author_by_any_email).and_call_original + + expect(commit.author).to eq(user) + key = "commit_author:#{commit.author_email}" + expect(RequestStore.store[key]).to eq(user) + + expect(commit.author).to eq(user) + RequestStore.store.clear + end + end + describe '#to_reference' do it 'returns a String reference to the object' do expect(commit.to_reference).to eq commit.id @@ -66,6 +86,27 @@ eos end end + describe '#full_title' do + it "returns no_commit_message when safe_message is blank" do + allow(commit).to receive(:safe_message).and_return('') + expect(commit.full_title).to eq("--no commit message") + end + + it "returns entire message if there is no newline" do + message = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit. Vivamus egestas lacinia lacus, sed rutrum mauris.' + + allow(commit).to receive(:safe_message).and_return(message) + expect(commit.full_title).to eq(message) + end + + it "returns first line of message if there is a newLine" do + message = commit.safe_message.split(" ").first + + allow(commit).to receive(:safe_message).and_return(message + "\n" + message) + expect(commit.full_title).to eq(message) + end + end + describe "delegation" do subject { commit } @@ -212,6 +253,7 @@ eos it 'returns the URI type at the given path' do expect(commit.uri_type('files/html')).to be(:tree) expect(commit.uri_type('files/images/logo-black.png')).to be(:raw) + expect(project.commit('video').uri_type('files/videos/intro.mp4')).to be(:raw) expect(commit.uri_type('files/js/application.js')).to be(:blob) end diff --git a/spec/models/compare_spec.rb b/spec/models/compare_spec.rb new file mode 100644 index 00000000000..49ab3c4b6e9 --- /dev/null +++ b/spec/models/compare_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Compare, models: true do + include RepoHelpers + + let(:project) { create(:project, :public) } + let(:commit) { project.commit } + + let(:start_commit) { sample_image_commit } + let(:head_commit) { sample_commit } + + let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, start_commit.id, head_commit.id) } + + subject { described_class.new(raw_compare, project) } + + describe '#start_commit' do + it 'returns raw compare base commit' do + expect(subject.start_commit.id).to eq(start_commit.id) + end + + it 'returns nil if compare base commit is nil' do + expect(raw_compare).to receive(:base).and_return(nil) + + expect(subject.start_commit).to eq(nil) + end + end + + describe '#commit' do + it 'returns raw compare head commit' do + expect(subject.commit.id).to eq(head_commit.id) + end + + it 'returns nil if compare head commit is nil' do + expect(raw_compare).to receive(:head).and_return(nil) + + expect(subject.commit).to eq(nil) + end + end + + describe '#base_commit' do + let(:base_commit) { Commit.new(another_sample_commit, project) } + + it 'returns project merge base commit' do + expect(project).to receive(:merge_base_commit).with(start_commit.id, head_commit.id).and_return(base_commit) + + expect(subject.base_commit).to eq(base_commit) + end + + it 'returns nil if there is no start_commit' do + expect(subject).to receive(:start_commit).and_return(nil) + + expect(subject.base_commit).to eq(nil) + end + + it 'returns nil if there is no head commit' do + expect(subject).to receive(:head_commit).and_return(nil) + + expect(subject.base_commit).to eq(nil) + end + end + + describe '#diff_refs' do + it 'uses base_commit sha as base_sha' do + expect(subject).to receive(:base_commit).at_least(:once).and_call_original + + expect(subject.diff_refs.base_sha).to eq(subject.base_commit.id) + end + + it 'uses start_commit sha as start_sha' do + expect(subject.diff_refs.start_sha).to eq(start_commit.id) + end + + it 'uses commit sha as head sha' do + expect(subject.diff_refs.head_sha).to eq(head_commit.id) + end + end +end diff --git a/spec/models/concerns/faster_cache_keys_spec.rb b/spec/models/concerns/faster_cache_keys_spec.rb new file mode 100644 index 00000000000..8d3f94267fa --- /dev/null +++ b/spec/models/concerns/faster_cache_keys_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe FasterCacheKeys do + describe '#cache_key' do + it 'returns a String' do + # We're using a fixed string here so it's easier to set an expectation for + # the resulting cache key. + time = '2016-08-08 16:39:00+02' + issue = build(:issue, updated_at: time) + issue.extend(described_class) + + expect(issue).to receive(:id).and_return(1) + + expect(issue.cache_key).to eq("issues/1-#{time}") + end + end +end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 5e652660e2c..549b0042038 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -68,7 +68,7 @@ describe Issue, "Mentionable" do describe '#create_cross_references!' do let(:project) { create(:project) } - let(:author) { double('author') } + let(:author) { build(:user) } let(:commit) { project.commit } let(:commit2) { project.commit } @@ -88,6 +88,10 @@ describe Issue, "Mentionable" do let(:author) { create(:author) } let(:issues) { create_list(:issue, 2, project: project, author: author) } + before do + project.team << [author, Gitlab::Access::DEVELOPER] + end + context 'before changes are persisted' do it 'ignores pre-existing references' do issue = create_issue(description: issues[0].to_reference) diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index af8e890ca95..1fa96eb1f15 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -119,7 +119,7 @@ describe DiffNote, models: true do context "when the merge request's diff refs don't match that of the diff note" do before do - allow(subject.noteable).to receive(:diff_refs).and_return(commit.diff_refs) + allow(subject.noteable).to receive(:diff_sha_refs).and_return(commit.diff_refs) end it "returns false" do @@ -168,7 +168,7 @@ describe DiffNote, models: true do context "when the note is outdated" do before do - allow(merge_request).to receive(:diff_refs).and_return(commit.diff_refs) + allow(merge_request).to receive(:diff_sha_refs).and_return(commit.diff_refs) end it "uses the DiffPositionUpdateService" do diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 7629af6a570..8a84ac0a7c7 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -11,4 +11,23 @@ describe Environment, models: true do it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } it { is_expected.to validate_length_of(:name).is_within(0..255) } + + it { is_expected.to validate_length_of(:external_url).is_within(0..255) } + + # To circumvent a not null violation of the name column: + # https://github.com/thoughtbot/shoulda-matchers/issues/336 + it 'validates uniqueness of :external_url' do + create(:environment) + + is_expected.to validate_uniqueness_of(:external_url).scoped_to(:project_id) + end + + describe '#nullify_external_url' do + it 'replaces a blank url with nil' do + env = build(:environment, external_url: "") + + expect(env.save).to be true + expect(env.external_url).to be_nil + end + end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 6a897c96690..3259f795296 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -306,4 +306,257 @@ describe Issue, models: true do expect(user2.assigned_open_issues_count).to eq(1) end end + + describe '#visible_to_user?' do + context 'with a user' do + let(:user) { build(:user) } + let(:issue) { build(:issue) } + + it 'returns true when the issue is readable' do + expect(issue).to receive(:readable_by?).with(user).and_return(true) + + expect(issue.visible_to_user?(user)).to eq(true) + end + + it 'returns false when the issue is not readable' do + expect(issue).to receive(:readable_by?).with(user).and_return(false) + + expect(issue.visible_to_user?(user)).to eq(false) + end + end + + context 'without a user' do + let(:issue) { build(:issue) } + + it 'returns true when the issue is publicly visible' do + expect(issue).to receive(:publicly_visible?).and_return(true) + + expect(issue.visible_to_user?).to eq(true) + end + + it 'returns false when the issue is not publicly visible' do + expect(issue).to receive(:publicly_visible?).and_return(false) + + expect(issue.visible_to_user?).to eq(false) + end + end + end + + describe '#readable_by?' do + describe 'with a regular user that is not a team member' do + let(:user) { create(:user) } + + context 'using a public project' do + let(:project) { create(:empty_project, :public) } + + it 'returns true for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).to be_readable_by(user) + end + + it 'returns false for a confidential issue' do + issue = build(:issue, project: project, confidential: true) + + expect(issue).not_to be_readable_by(user) + end + end + + context 'using an internal project' do + let(:project) { create(:empty_project, :internal) } + + context 'using an internal user' do + it 'returns true for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).to be_readable_by(user) + end + + it 'returns false for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).not_to be_readable_by(user) + end + end + + context 'using an external user' do + before do + allow(user).to receive(:external?).and_return(true) + end + + it 'returns false for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).not_to be_readable_by(user) + end + + it 'returns false for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).not_to be_readable_by(user) + end + end + end + + context 'using a private project' do + let(:project) { create(:empty_project, :private) } + + it 'returns false for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).not_to be_readable_by(user) + end + + it 'returns false for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).not_to be_readable_by(user) + end + + context 'when the user is the project owner' do + it 'returns true for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).not_to be_readable_by(user) + end + + it 'returns true for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).not_to be_readable_by(user) + end + end + end + end + + context 'with a regular user that is a team member' do + let(:user) { create(:user) } + let(:project) { create(:empty_project, :public) } + + context 'using a public project' do + before do + project.team << [user, Gitlab::Access::DEVELOPER] + end + + it 'returns true for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).to be_readable_by(user) + end + + it 'returns true for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).to be_readable_by(user) + end + end + + context 'using an internal project' do + let(:project) { create(:empty_project, :internal) } + + before do + project.team << [user, Gitlab::Access::DEVELOPER] + end + + it 'returns true for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).to be_readable_by(user) + end + + it 'returns true for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).to be_readable_by(user) + end + end + + context 'using a private project' do + let(:project) { create(:empty_project, :private) } + + before do + project.team << [user, Gitlab::Access::DEVELOPER] + end + + it 'returns true for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).to be_readable_by(user) + end + + it 'returns true for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).to be_readable_by(user) + end + end + end + + context 'with an admin user' do + let(:project) { create(:empty_project) } + let(:user) { create(:user, admin: true) } + + it 'returns true for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).to be_readable_by(user) + end + + it 'returns true for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).to be_readable_by(user) + end + end + end + + describe '#publicly_visible?' do + context 'using a public project' do + let(:project) { create(:empty_project, :public) } + + it 'returns true for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).to be_publicly_visible + end + + it 'returns false for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).not_to be_publicly_visible + end + end + + context 'using an internal project' do + let(:project) { create(:empty_project, :internal) } + + it 'returns false for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).not_to be_publicly_visible + end + + it 'returns false for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).not_to be_publicly_visible + end + end + + context 'using a private project' do + let(:project) { create(:empty_project, :private) } + + it 'returns false for a regular issue' do + issue = build(:issue, project: project) + + expect(issue).not_to be_publicly_visible + end + + it 'returns false for a confidential issue' do + issue = build(:issue, :confidential, project: project) + + expect(issue).not_to be_publicly_visible + end + end + end end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 49cf3d8633a..6d68e52a822 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -16,12 +16,13 @@ describe Key, models: true do end describe "Methods" do + let(:user) { create(:user) } it { is_expected.to respond_to :projects } it { is_expected.to respond_to :publishable_key } describe "#publishable_keys" do - it 'strips all personal information' do - expect(build(:key).publishable_key).not_to match(/dummy@gitlab/) + it 'replaces SSH key comment with simple identifier of username + hostname' do + expect(build(:key, user: user).publishable_key).to include("#{user.name} (localhost)") end end end diff --git a/spec/models/legacy_diff_note_spec.rb b/spec/models/legacy_diff_note_spec.rb index d23fc06c3ad..c8ee656fe3b 100644 --- a/spec/models/legacy_diff_note_spec.rb +++ b/spec/models/legacy_diff_note_spec.rb @@ -58,7 +58,7 @@ describe LegacyDiffNote, models: true do # Generate a real line_code value so we know it will match. We use a # random line from a random diff just for funsies. - diff = merge.diffs.to_a.sample + diff = merge.raw_diffs.to_a.sample line = Gitlab::Diff::Parser.new.parse(diff.diff.each_line).to_a.sample code = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos) diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 40181a8b906..44cd3c08718 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -79,6 +79,18 @@ describe Member, models: true do @accepted_request_member = project.requesters.find_by(user_id: accepted_request_user.id).tap { |m| m.accept_request } end + describe '.access_for_user_ids' do + it 'returns the right access levels' do + users = [@owner_user.id, @master_user.id] + expected = { + @owner_user.id => Gitlab::Access::OWNER, + @master_user.id => Gitlab::Access::MASTER + } + + expect(described_class.access_for_user_ids(users)).to eq(expected) + end + end + describe '.invite' do it { expect(described_class.invite).not_to include @master } it { expect(described_class.invite).to include @invited_member } diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index ba622dfb9be..e7c0c506463 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -101,7 +101,7 @@ describe ProjectMember, models: true do end end - describe '.add_users_into_projects' do + describe '.add_users_to_projects' do before do @project_1 = create :project @project_2 = create :project @@ -109,7 +109,7 @@ describe ProjectMember, models: true do @user_1 = create :user @user_2 = create :user - ProjectMember.add_users_into_projects( + ProjectMember.add_users_to_projects( [@project_1.id, @project_2.id], [@user_1.id, @user_2.id], ProjectMember::MASTER diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index 9a637c94fbe..29f7396f862 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -10,7 +10,7 @@ describe MergeRequestDiff, models: true do expect(mr_diff).not_to receive(:load_diffs) expect(Gitlab::Git::Compare).to receive(:new).and_call_original - mr_diff.diffs(ignore_whitespace_change: true) + mr_diff.raw_diffs(ignore_whitespace_change: true) end end @@ -18,19 +18,19 @@ describe MergeRequestDiff, models: true do before { mr_diff.update_attributes(st_diffs: '') } it 'returns an empty DiffCollection' do - expect(mr_diff.diffs).to be_a(Gitlab::Git::DiffCollection) - expect(mr_diff.diffs).to be_empty + expect(mr_diff.raw_diffs).to be_a(Gitlab::Git::DiffCollection) + expect(mr_diff.raw_diffs).to be_empty end end context 'when the raw diffs exist' do it 'returns the diffs' do - expect(mr_diff.diffs).to be_a(Gitlab::Git::DiffCollection) - expect(mr_diff.diffs).not_to be_empty + expect(mr_diff.raw_diffs).to be_a(Gitlab::Git::DiffCollection) + expect(mr_diff.raw_diffs).not_to be_empty end context 'when the :paths option is set' do - let(:diffs) { mr_diff.diffs(paths: ['files/ruby/popen.rb', 'files/ruby/popen.rb']) } + let(:diffs) { mr_diff.raw_diffs(paths: ['files/ruby/popen.rb', 'files/ruby/popen.rb']) } it 'only returns diffs that match the (old path, new path) given' do expect(diffs.map(&:new_path)).to contain_exactly('files/ruby/popen.rb') diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index c8ad7ab3e7f..d793cfd0bde 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -65,11 +65,11 @@ describe MergeRequest, models: true do end describe '#target_branch_sha' do - context 'when the target branch does not exist anymore' do - let(:project) { create(:project) } + let(:project) { create(:project) } - subject { create(:merge_request, source_project: project, target_project: project) } + subject { create(:merge_request, source_project: project, target_project: project) } + context 'when the target branch does not exist' do before do project.repository.raw_repository.delete_branch(subject.target_branch) end @@ -78,6 +78,12 @@ describe MergeRequest, models: true do expect(subject.target_branch_sha).to be_nil end end + + it 'returns memoized value' do + subject.target_branch_sha = '8ffb3c15a5475e59ae909384297fede4badcb4c7' + + expect(subject.target_branch_sha).to eq '8ffb3c15a5475e59ae909384297fede4badcb4c7' + end end describe '#source_branch_sha' do @@ -103,6 +109,12 @@ describe MergeRequest, models: true do expect(subject.source_branch_sha).to be_nil end end + + it 'returns memoized value' do + subject.source_branch_sha = '2e5d3239642f9161dcbbc4b70a211a68e5e45e2b' + + expect(subject.source_branch_sha).to eq '2e5d3239642f9161dcbbc4b70a211a68e5e45e2b' + end end describe '#to_reference' do @@ -116,6 +128,31 @@ describe MergeRequest, models: true do end end + describe '#raw_diffs' do + let(:merge_request) { build(:merge_request) } + let(:options) { { paths: ['a/b', 'b/a', 'c/*'] } } + + context 'when there are MR diffs' do + it 'delegates to the MR diffs' do + merge_request.merge_request_diff = MergeRequestDiff.new + + expect(merge_request.merge_request_diff).to receive(:raw_diffs).with(options) + + merge_request.raw_diffs(options) + end + end + + context 'when there are no MR diffs' do + it 'delegates to the compare object' do + merge_request.compare = double(:compare) + + expect(merge_request.compare).to receive(:raw_diffs).with(options) + + merge_request.raw_diffs(options) + end + end + end + describe '#diffs' do let(:merge_request) { build(:merge_request) } let(:options) { { paths: ['a/b', 'b/a', 'c/*'] } } @@ -124,7 +161,7 @@ describe MergeRequest, models: true do it 'delegates to the MR diffs' do merge_request.merge_request_diff = MergeRequestDiff.new - expect(merge_request.merge_request_diff).to receive(:diffs).with(options) + expect(merge_request.merge_request_diff).to receive(:raw_diffs).with(hash_including(options)) merge_request.diffs(options) end @@ -648,6 +685,12 @@ describe MergeRequest, models: true do subject.reload_diff end + it "executs diff cache service" do + expect_any_instance_of(MergeRequests::MergeRequestDiffCacheService).to receive(:execute).with(subject) + + subject.reload_diff + end + it "updates diff note positions" do old_diff_refs = subject.diff_refs @@ -674,4 +717,28 @@ describe MergeRequest, models: true do subject.reload_diff end end + + describe "#diff_sha_refs" do + context "with diffs" do + subject { create(:merge_request, :with_diffs) } + + it "does not touch the repository" do + subject # Instantiate the object + + expect_any_instance_of(Repository).not_to receive(:commit) + + subject.diff_sha_refs + end + + it "returns expected diff_refs" do + expected_diff_refs = Gitlab::Diff::DiffRefs.new( + base_sha: subject.merge_request_diff.base_commit_sha, + start_sha: subject.merge_request_diff.start_commit_sha, + head_sha: subject.merge_request_diff.head_commit_sha + ) + + expect(subject.diff_sha_refs).to eq(expected_diff_refs) + end + end + end end diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb index 5f618322aab..62ae5f6cf74 100644 --- a/spec/models/project_services/hipchat_service_spec.rb +++ b/spec/models/project_services/hipchat_service_spec.rb @@ -340,18 +340,36 @@ describe HipchatService, models: true do end context "#message_options" do - it "should be set to the defaults" do - expect(hipchat.send(:message_options)).to eq({ notify: false, color: 'yellow' }) + it "is set to the defaults" do + expect(hipchat.__send__(:message_options)).to eq({ notify: false, color: 'yellow' }) end - it "should set notfiy to true" do + it "sets notify to true" do allow(hipchat).to receive(:notify).and_return('1') - expect(hipchat.send(:message_options)).to eq({ notify: true, color: 'yellow' }) + + expect(hipchat.__send__(:message_options)).to eq({ notify: true, color: 'yellow' }) end - it "should set the color" do + it "sets the color" do allow(hipchat).to receive(:color).and_return('red') - expect(hipchat.send(:message_options)).to eq({ notify: false, color: 'red' }) + + expect(hipchat.__send__(:message_options)).to eq({ notify: false, color: 'red' }) + end + + context 'with a successful build' do + it 'uses the green color' do + build_data = { object_kind: 'build', commit: { status: 'success' } } + + expect(hipchat.__send__(:message_options, build_data)).to eq({ notify: false, color: 'green' }) + end + end + + context 'with a failed build' do + it 'uses the red color' do + build_data = { object_kind: 'build', commit: { status: 'failed' } } + + expect(hipchat.__send__(:message_options, build_data)).to eq({ notify: false, color: 'red' }) + end end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 9b017288488..567f87b9970 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -69,6 +69,7 @@ describe Project, models: true do it { is_expected.to include_module(Gitlab::ConfigHelper) } it { is_expected.to include_module(Gitlab::ShellAdapter) } it { is_expected.to include_module(Gitlab::VisibilityLevel) } + it { is_expected.to include_module(Gitlab::CurrentSettings) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } end @@ -245,6 +246,34 @@ describe Project, models: true do end end + describe "#new_issue_address" do + let(:project) { create(:empty_project, path: "somewhere") } + let(:user) { create(:user) } + + context 'incoming email enabled' do + before do + stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") + end + + it 'returns the address to create a new issue' do + token = user.authentication_token + address = "p+#{project.namespace.path}/#{project.path}+#{token}@gl.ab" + + expect(project.new_issue_address(user)).to eq(address) + end + end + + context 'incoming email disabled' do + before do + stub_incoming_email_setting(enabled: false) + end + + it 'returns nil' do + expect(project.new_issue_address(user)).to be_nil + end + end + end + describe 'last_activity methods' do let(:project) { create(:project) } let(:last_event) { double(created_at: Time.now) } @@ -372,6 +401,24 @@ describe Project, models: true do it { expect(@project.to_param).to eq('gitlabhq') } end + + context 'with invalid path' do + it 'returns previous path to keep project suitable for use in URLs when persisted' do + project = create(:empty_project, path: 'gitlab') + project.path = 'foo&bar' + + expect(project).not_to be_valid + expect(project.to_param).to eq 'gitlab' + end + + it 'returns current path when new record' do + project = build(:empty_project, path: 'gitlab') + project.path = 'foo&bar' + + expect(project).not_to be_valid + expect(project.to_param).to eq 'foo&bar' + end + end end describe '#repository' do @@ -1024,68 +1071,97 @@ describe Project, models: true do end describe '#protected_branch?' do - let(:project) { create(:empty_project) } + context 'existing project' do + let(:project) { create(:project) } - it 'returns true when the branch matches a protected branch via direct match' do - project.protected_branches.create!(name: 'foo') + it 'returns true when the branch matches a protected branch via direct match' do + project.protected_branches.create!(name: 'foo') - expect(project.protected_branch?('foo')).to eq(true) - end + expect(project.protected_branch?('foo')).to eq(true) + end - it 'returns true when the branch matches a protected branch via wildcard match' do - project.protected_branches.create!(name: 'production/*') + it 'returns true when the branch matches a protected branch via wildcard match' do + project.protected_branches.create!(name: 'production/*') - expect(project.protected_branch?('production/some-branch')).to eq(true) - end + expect(project.protected_branch?('production/some-branch')).to eq(true) + end - it 'returns false when the branch does not match a protected branch via direct match' do - expect(project.protected_branch?('foo')).to eq(false) - end + it 'returns false when the branch does not match a protected branch via direct match' do + expect(project.protected_branch?('foo')).to eq(false) + end - it 'returns false when the branch does not match a protected branch via wildcard match' do - project.protected_branches.create!(name: 'production/*') + it 'returns false when the branch does not match a protected branch via wildcard match' do + project.protected_branches.create!(name: 'production/*') - expect(project.protected_branch?('staging/some-branch')).to eq(false) + expect(project.protected_branch?('staging/some-branch')).to eq(false) + end end - end - describe "#developers_can_push_to_protected_branch?" do - let(:project) { create(:empty_project) } + context "new project" do + let(:project) { create(:empty_project) } - context "when the branch matches a protected branch via direct match" do - it "returns true if 'Developers can Push' is turned on" do - create(:protected_branch, name: "production", project: project, developers_can_push: true) + it 'returns false when default_protected_branch is unprotected' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) - expect(project.developers_can_push_to_protected_branch?('production')).to be true + expect(project.protected_branch?('master')).to be false end - it "returns false if 'Developers can Push' is turned off" do - create(:protected_branch, name: "production", project: project, developers_can_push: false) + it 'returns false when default_protected_branch lets developers push' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) - expect(project.developers_can_push_to_protected_branch?('production')).to be false + expect(project.protected_branch?('master')).to be false end - end - context "when the branch matches a protected branch via wilcard match" do - it "returns true if 'Developers can Push' is turned on" do - create(:protected_branch, name: "production/*", project: project, developers_can_push: true) + it 'returns true when default_branch_protection does not let developers push but let developer merge branches' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE) - expect(project.developers_can_push_to_protected_branch?('production/some-branch')).to be true + expect(project.protected_branch?('master')).to be true end - it "returns false if 'Developers can Push' is turned off" do - create(:protected_branch, name: "production/*", project: project, developers_can_push: false) + it 'returns true when default_branch_protection is in full protection' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL) - expect(project.developers_can_push_to_protected_branch?('production/some-branch')).to be false + expect(project.protected_branch?('master')).to be true end end + end + + describe '#user_can_push_to_empty_repo?' do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } - context "when the branch does not match a protected branch" do - it "returns false" do - create(:protected_branch, name: "production/*", project: project, developers_can_push: true) + it 'returns false when default_branch_protection is in full protection and user is developer' do + project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL) - expect(project.developers_can_push_to_protected_branch?('staging/some-branch')).to be false - end + expect(project.user_can_push_to_empty_repo?(user)).to be_falsey + end + + it 'returns false when default_branch_protection only lets devs merge and user is dev' do + project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE) + + expect(project.user_can_push_to_empty_repo?(user)).to be_falsey + end + + it 'returns true when default_branch_protection lets devs push and user is developer' do + project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) + + expect(project.user_can_push_to_empty_repo?(user)).to be_truthy + end + + it 'returns true when default_branch_protection is unprotected and user is developer' do + project.team << [user, :developer] + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) + + expect(project.user_can_push_to_empty_repo?(user)).to be_truthy + end + + it 'returns true when user is master' do + project.team << [user, :master] + + expect(project.user_can_push_to_empty_repo?(user)).to be_truthy end end @@ -1244,6 +1320,32 @@ describe Project, models: true do end end + describe '#add_import_job' do + context 'forked' do + let(:forked_project_link) { create(:forked_project_link) } + let(:forked_from_project) { forked_project_link.forked_from_project } + let(:project) { forked_project_link.forked_to_project } + + it 'schedules a RepositoryForkWorker job' do + expect(RepositoryForkWorker).to receive(:perform_async). + with(project.id, forked_from_project.repository_storage_path, + forked_from_project.path_with_namespace, project.namespace.path) + + project.add_import_job + end + end + + context 'not forked' do + let(:project) { create(:project) } + + it 'schedules a RepositoryImportWorker job' do + expect(RepositoryImportWorker).to receive(:perform_async).with(project.id) + + project.add_import_job + end + end + end + describe '.where_paths_in' do context 'without any paths' do it 'returns an empty relation' do diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 9262aeb6ed8..5eaf0d3b7a6 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -151,8 +151,8 @@ describe ProjectTeam, models: true do it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::MASTER) } it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } it { expect(project.team.max_member_access(guest.id)).to eq(Gitlab::Access::GUEST) } - it { expect(project.team.max_member_access(nonmember.id)).to be_nil } - it { expect(project.team.max_member_access(requester.id)).to be_nil } + it { expect(project.team.max_member_access(nonmember.id)).to eq(Gitlab::Access::NO_ACCESS) } + it { expect(project.team.max_member_access(requester.id)).to eq(Gitlab::Access::NO_ACCESS) } end context 'when project is shared with group' do @@ -168,14 +168,14 @@ describe ProjectTeam, models: true do it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::DEVELOPER) } it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } - it { expect(project.team.max_member_access(nonmember.id)).to be_nil } - it { expect(project.team.max_member_access(requester.id)).to be_nil } + it { expect(project.team.max_member_access(nonmember.id)).to eq(Gitlab::Access::NO_ACCESS) } + it { expect(project.team.max_member_access(requester.id)).to eq(Gitlab::Access::NO_ACCESS) } context 'but share_with_group_lock is true' do before { project.namespace.update(share_with_group_lock: true) } - it { expect(project.team.max_member_access(master.id)).to be_nil } - it { expect(project.team.max_member_access(reporter.id)).to be_nil } + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::NO_ACCESS) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::NO_ACCESS) } end end end @@ -194,8 +194,74 @@ describe ProjectTeam, models: true do it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::MASTER) } it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } it { expect(project.team.max_member_access(guest.id)).to eq(Gitlab::Access::GUEST) } - it { expect(project.team.max_member_access(nonmember.id)).to be_nil } - it { expect(project.team.max_member_access(requester.id)).to be_nil } + it { expect(project.team.max_member_access(nonmember.id)).to eq(Gitlab::Access::NO_ACCESS) } + it { expect(project.team.max_member_access(requester.id)).to eq(Gitlab::Access::NO_ACCESS) } end end + + shared_examples_for "#max_member_access_for_users" do |enable_request_store| + describe "#max_member_access_for_users" do + before do + RequestStore.begin! if enable_request_store + end + + after do + if enable_request_store + RequestStore.end! + RequestStore.clear! + end + end + + it 'returns correct roles for different users' do + master = create(:user) + reporter = create(:user) + promoted_guest = create(:user) + guest = create(:user) + project = create(:project) + + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [promoted_guest, :guest] + project.team << [guest, :guest] + + group = create(:group) + group_developer = create(:user) + second_developer = create(:user) + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::DEVELOPER) + + group.add_master(promoted_guest) + group.add_developer(group_developer) + group.add_developer(second_developer) + + second_group = create(:group) + project.project_group_links.create( + group: second_group, + group_access: Gitlab::Access::MASTER) + second_group.add_master(second_developer) + + users = [master, reporter, promoted_guest, guest, group_developer, second_developer].map(&:id) + + expected = { + master.id => Gitlab::Access::MASTER, + reporter.id => Gitlab::Access::REPORTER, + promoted_guest.id => Gitlab::Access::DEVELOPER, + guest.id => Gitlab::Access::GUEST, + group_developer.id => Gitlab::Access::DEVELOPER, + second_developer.id => Gitlab::Access::MASTER + } + + expect(project.team.max_member_access_for_user_ids(users)).to eq(expected) + end + end + end + + describe '#max_member_access_for_users with RequestStore' do + it_behaves_like "#max_member_access_for_users", true + end + + describe '#max_member_access_for_users without RequestStore' do + it_behaves_like "#max_member_access_for_users", false + end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 3e133143bbc..2a053b1804f 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -383,9 +383,13 @@ describe Repository, models: true do end describe '#rm_branch' do + let(:old_rev) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } # git rev-parse feature + let(:blank_sha) { '0000000000000000000000000000000000000000' } + context 'when pre hooks were successful' do it 'should run without errors' do - allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, nil]) + expect_any_instance_of(GitHooksService).to receive(:execute). + with(user, project.repository.path_to_repo, old_rev, blank_sha, 'refs/heads/feature') expect { repository.rm_branch(user, 'feature') }.not_to raise_error end @@ -420,10 +424,13 @@ describe Repository, models: true do end describe '#commit_with_hooks' do + let(:old_rev) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } # git rev-parse feature + context 'when pre hooks were successful' do before do expect_any_instance_of(GitHooksService).to receive(:execute). - and_return(true) + with(user, repository.path_to_repo, old_rev, sample_commit.id, 'refs/heads/feature'). + and_yield.and_return(true) end it 'should run without errors' do @@ -437,6 +444,14 @@ describe Repository, models: true do repository.commit_with_hooks(user, 'feature') { sample_commit.id } end + + context "when the branch wasn't empty" do + it 'updates the head' do + expect(repository.find_branch('feature').target.id).to eq(old_rev) + repository.commit_with_hooks(user, 'feature') { sample_commit.id } + expect(repository.find_branch('feature').target.id).to eq(sample_commit.id) + end + end end context 'when pre hooks failed' do @@ -448,6 +463,43 @@ describe Repository, models: true do end.to raise_error(GitHooksService::PreReceiveError) end end + + context 'when target branch is different from source branch' do + before do + allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, '']) + end + + it 'expires branch cache' do + expect(repository).not_to receive(:expire_exists_cache) + expect(repository).not_to receive(:expire_root_ref_cache) + expect(repository).not_to receive(:expire_emptiness_caches) + expect(repository).to receive(:expire_branches_cache) + expect(repository).to receive(:expire_has_visible_content_cache) + expect(repository).to receive(:expire_branch_count_cache) + + repository.commit_with_hooks(user, 'new-feature') { sample_commit.id } + end + end + + context 'when repository is empty' do + before do + allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, '']) + end + + it 'expires creation and branch cache' do + empty_repository = create(:empty_project, :empty_repo).repository + + expect(empty_repository).to receive(:expire_exists_cache) + expect(empty_repository).to receive(:expire_root_ref_cache) + expect(empty_repository).to receive(:expire_emptiness_caches) + expect(empty_repository).to receive(:expire_branches_cache) + expect(empty_repository).to receive(:expire_has_visible_content_cache) + expect(empty_repository).to receive(:expire_branch_count_cache) + + empty_repository.commit_file(user, 'CHANGELOG', 'Changelog!', + 'Updates file content', 'master', false) + end + end end describe '#exists?' do @@ -1102,7 +1154,7 @@ describe Repository, models: true do it 'does not flush the cache if the commit does not change any logos' do diff = double(:diff, new_path: 'test.txt') - expect(commit).to receive(:diffs).and_return([diff]) + expect(commit).to receive(:raw_diffs).and_return([diff]) expect(cache).not_to receive(:expire) repository.expire_avatar_cache(repository.root_ref, '123') @@ -1111,7 +1163,7 @@ describe Repository, models: true do it 'flushes the cache if the commit changes any of the logos' do diff = double(:diff, new_path: Repository::AVATAR_FILES[0]) - expect(commit).to receive(:diffs).and_return([diff]) + expect(commit).to receive(:raw_diffs).and_return([diff]) expect(cache).to receive(:expire).with(:avatar) repository.expire_avatar_cache(repository.root_ref, '123') @@ -1164,10 +1216,30 @@ describe Repository, models: true do end describe "#keep_around" do + it "does not fail if we attempt to reference bad commit" do + expect(repository.kept_around?('abc1234')).to be_falsey + end + it "stores a reference to the specified commit sha so it isn't garbage collected" do repository.keep_around(sample_commit.id) expect(repository.kept_around?(sample_commit.id)).to be_truthy end + + it "attempting to call keep_around on truncated ref does not fail" do + repository.keep_around(sample_commit.id) + ref = repository.send(:keep_around_ref_name, sample_commit.id) + path = File.join(repository.path, ref) + # Corrupt the reference + File.truncate(path, 0) + + expect(repository.kept_around?(sample_commit.id)).to be_falsey + + repository.keep_around(sample_commit.id) + + expect(repository.kept_around?(sample_commit.id)).to be_falsey + + File.delete(path) + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2a5a7fb2fc6..9f432501c59 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -643,7 +643,7 @@ describe User, models: true do user = create :user key = create :key, key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD33bWLBxu48Sev9Fert1yzEO4WGcWglWF7K/AwblIUFselOt/QdOL9DSjpQGxLagO1s9wl53STIO8qGS4Ms0EJZyIXOEFMjFJ5xmjSy+S37By4sG7SsltQEHMxtbtFOaW5LV2wCrX+rUsRNqLMamZjgjcPO0/EgGCXIGMAYW4O7cwGZdXWYIhQ1Vwy+CsVMDdPkPgBXqK7nR/ey8KMs8ho5fMNgB5hBw/AL9fNGhRw3QTD6Q12Nkhl4VZES2EsZqlpNnJttnPdp847DUsT6yuLRlfiQfz5Cn9ysHFdXObMN5VYIiPFwHeYCZp1X2S4fDZooRE8uOLTfxWHPXwrhqSH", user_id: user.id - expect(user.all_ssh_keys).to include(key.key) + expect(user.all_ssh_keys).to include(a_string_starting_with(key.key)) end end diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index 719da27f919..e8fd697965f 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -112,7 +112,7 @@ describe API::API, api: true do before do project.repository.add_branch(user, protected_branch, 'master') - create(:protected_branch, project: project, name: protected_branch, developers_can_push: true, developers_can_merge: true) + create(:protected_branch, :developers_can_push, :developers_can_merge, project: project, name: protected_branch) end it 'updates that a developer can push' do diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 5219c808791..51ee2167d47 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -73,9 +73,13 @@ describe API::API, api: true do context "authorized user" do it "should return a commit by sha" do get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) + expect(response).to have_http_status(200) expect(json_response['id']).to eq(project.repository.commit.id) expect(json_response['title']).to eq(project.repository.commit.title) + expect(json_response['stats']['additions']).to eq(project.repository.commit.stats.additions) + expect(json_response['stats']['deletions']).to eq(project.repository.commit.stats.deletions) + expect(json_response['stats']['total']).to eq(project.repository.commit.stats.total) end it "should return a 404 error if not found" do @@ -169,10 +173,10 @@ describe API::API, api: true do end it 'should return the inline comment' do - post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment', path: project.repository.commit.diffs.first.new_path, line: 7, line_type: 'new' + post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment', path: project.repository.commit.raw_diffs.first.new_path, line: 7, line_type: 'new' expect(response).to have_http_status(201) expect(json_response['note']).to eq('My comment') - expect(json_response['path']).to eq(project.repository.commit.diffs.first.new_path) + expect(json_response['path']).to eq(project.repository.commit.raw_diffs.first.new_path) expect(json_response['line']).to eq(7) expect(json_response['line_type']).to eq('new') end diff --git a/spec/requests/api/deploy_keys.rb b/spec/requests/api/deploy_keys.rb deleted file mode 100644 index ac42288bc34..00000000000 --- a/spec/requests/api/deploy_keys.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'spec_helper' - -describe API::API, api: true do - include ApiHelpers - - let(:user) { create(:user) } - let(:project) { create(:project, creator_id: user.id) } - let!(:deploy_keys_project) { create(:deploy_keys_project, project: project) } - let(:admin) { create(:admin) } - - describe 'GET /deploy_keys' do - before { admin } - - context 'when unauthenticated' do - it 'should return authentication error' do - get api('/deploy_keys') - expect(response.status).to eq(401) - end - end - - context 'when authenticated as non-admin user' do - it 'should return a 403 error' do - get api('/deploy_keys', user) - expect(response.status).to eq(403) - end - end - - context 'when authenticated as admin' do - it 'should return all deploy keys' do - get api('/deploy_keys', admin) - expect(response.status).to eq(200) - - expect(json_response).to be_an Array - expect(json_response.first['id']).to eq(deploy_keys_project.deploy_key.id) - end - end - end -end diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb new file mode 100644 index 00000000000..7d8cc45327c --- /dev/null +++ b/spec/requests/api/deploy_keys_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:admin) { create(:admin) } + let(:project) { create(:project, creator_id: user.id) } + let(:deploy_key) { create(:deploy_key, public: true) } + + let!(:deploy_keys_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key) + end + + describe 'GET /deploy_keys' do + context 'when unauthenticated' do + it 'should return authentication error' do + get api('/deploy_keys') + + expect(response.status).to eq(401) + end + end + + context 'when authenticated as non-admin user' do + it 'should return a 403 error' do + get api('/deploy_keys', user) + + expect(response.status).to eq(403) + end + end + + context 'when authenticated as admin' do + it 'should return all deploy keys' do + get api('/deploy_keys', admin) + + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.first['id']).to eq(deploy_keys_project.deploy_key.id) + end + end + end + + describe 'GET /projects/:id/deploy_keys' do + before { deploy_key } + + it 'should return array of ssh keys' do + get api("/projects/#{project.id}/deploy_keys", admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['title']).to eq(deploy_key.title) + end + end + + describe 'GET /projects/:id/deploy_keys/:key_id' do + it 'should return a single key' do + get api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(deploy_key.title) + end + + it 'should return 404 Not Found with invalid ID' do + get api("/projects/#{project.id}/deploy_keys/404", admin) + + expect(response).to have_http_status(404) + end + end + + describe 'POST /projects/:id/deploy_keys' do + it 'should not create an invalid ssh key' do + post api("/projects/#{project.id}/deploy_keys", admin), { title: 'invalid key' } + + expect(response).to have_http_status(400) + expect(json_response['message']['key']).to eq([ + 'can\'t be blank', + 'is too short (minimum is 0 characters)', + 'is invalid' + ]) + end + + it 'should not create a key without title' do + post api("/projects/#{project.id}/deploy_keys", admin), key: 'some key' + + expect(response).to have_http_status(400) + expect(json_response['message']['title']).to eq([ + 'can\'t be blank', + 'is too short (minimum is 0 characters)' + ]) + end + + it 'should create new ssh key' do + key_attrs = attributes_for :another_key + + expect do + post api("/projects/#{project.id}/deploy_keys", admin), key_attrs + end.to change{ project.deploy_keys.count }.by(1) + end + end + + describe 'DELETE /projects/:id/deploy_keys/:key_id' do + before { deploy_key } + + it 'should delete existing key' do + expect do + delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin) + end.to change{ project.deploy_keys.count }.by(-1) + end + + it 'should return 404 Not Found with invalid ID' do + delete api("/projects/#{project.id}/deploy_keys/404", admin) + + expect(response).to have_http_status(404) + end + end + + describe 'POST /projects/:id/deploy_keys/:key_id/enable' do + let(:project2) { create(:empty_project) } + + context 'when the user can admin the project' do + it 'enables the key' do + expect do + post api("/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable", admin) + end.to change { project2.deploy_keys.count }.from(0).to(1) + + expect(response).to have_http_status(201) + expect(json_response['id']).to eq(deploy_key.id) + end + end + + context 'when authenticated as non-admin user' do + it 'should return a 404 error' do + post api("/projects/#{project2.id}/deploy_keys/#{deploy_key.id}/enable", user) + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /projects/:id/deploy_keys/:key_id/disable' do + context 'when the user can admin the project' do + it 'disables the key' do + expect do + delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}/disable", admin) + end.to change { project.deploy_keys.count }.from(1).to(0) + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq(deploy_key.id) + end + end + + context 'when authenticated as non-admin user' do + it 'should return a 404 error' do + delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}/disable", user) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb new file mode 100644 index 00000000000..05e57905343 --- /dev/null +++ b/spec/requests/api/environments_spec.rb @@ -0,0 +1,129 @@ +require 'spec_helper' + +describe API::API, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { create(:project, :private, namespace: user.namespace) } + let!(:environment) { create(:environment, project: project) } + + before do + project.team << [user, :master] + end + + describe 'GET /projects/:id/environments' do + context 'as member of the project' do + it_behaves_like 'a paginated resources' do + let(:request) { get api("/projects/#{project.id}/environments", user) } + end + + it 'returns project environments' do + get api("/projects/#{project.id}/environments", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.first['name']).to eq(environment.name) + expect(json_response.first['external_url']).to eq(environment.external_url) + end + end + + context 'as non member' do + it 'returns a 404 status code' do + get api("/projects/#{project.id}/environments", non_member) + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /projects/:id/environments' do + context 'as a member' do + it 'creates a environment with valid params' do + post api("/projects/#{project.id}/environments", user), name: "mepmep" + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq('mepmep') + expect(json_response['external']).to be nil + end + + it 'requires name to be passed' do + post api("/projects/#{project.id}/environments", user), external_url: 'test.gitlab.com' + + expect(response).to have_http_status(400) + end + + it 'returns a 400 if environment already exists' do + post api("/projects/#{project.id}/environments", user), name: environment.name + + expect(response).to have_http_status(400) + end + end + + context 'a non member' do + it 'rejects the request' do + post api("/projects/#{project.id}/environments", non_member), name: 'gitlab.com' + + expect(response).to have_http_status(404) + end + + it 'returns a 400 when the required params are missing' do + post api("/projects/12345/environments", non_member), external_url: 'http://env.git.com' + end + end + end + + describe 'PUT /projects/:id/environments/:environment_id' do + it 'returns a 200 if name and external_url are changed' do + url = 'https://mepmep.whatever.ninja' + put api("/projects/#{project.id}/environments/#{environment.id}", user), + name: 'Mepmep', external_url: url + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq('Mepmep') + expect(json_response['external_url']).to eq(url) + end + + it "won't update the external_url if only the name is passed" do + url = environment.external_url + put api("/projects/#{project.id}/environments/#{environment.id}", user), + name: 'Mepmep' + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq('Mepmep') + expect(json_response['external_url']).to eq(url) + end + + it 'returns a 404 if the environment does not exist' do + put api("/projects/#{project.id}/environments/12345", user) + + expect(response).to have_http_status(404) + end + end + + describe 'DELETE /projects/:id/environments/:environment_id' do + context 'as a master' do + it 'returns a 200 for an existing environment' do + delete api("/projects/#{project.id}/environments/#{environment.id}", user) + + expect(response).to have_http_status(200) + end + + it 'returns a 404 for non existing id' do + delete api("/projects/#{project.id}/environments/12345", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Not found') + end + end + + context 'a non member' do + it 'rejects the request' do + delete api("/projects/#{project.id}/environments/#{environment.id}", non_member) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 12f2cfa6942..9d3d28e0b91 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -531,10 +531,8 @@ describe API::API, api: true do describe 'POST /projects/:id/issues with spam filtering' do before do - Grape::Endpoint.before_each do |endpoint| - allow(endpoint).to receive(:check_for_spam?).and_return(true) - allow(endpoint).to receive(:is_spam?).and_return(true) - end + allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) end let(:params) do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 8c6a7e6529d..6b78326213b 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -641,79 +641,7 @@ describe API::API, api: true do expect(response).to have_http_status(404) end end - - describe :deploy_keys do - let(:deploy_keys_project) { create(:deploy_keys_project, project: project) } - let(:deploy_key) { deploy_keys_project.deploy_key } - - describe 'GET /projects/:id/deploy_keys' do - before { deploy_key } - - it 'should return array of ssh keys' do - get api("/projects/#{project.id}/deploy_keys", user) - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.first['title']).to eq(deploy_key.title) - end - end - - describe 'GET /projects/:id/deploy_keys/:key_id' do - it 'should return a single key' do - get api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", user) - expect(response).to have_http_status(200) - expect(json_response['title']).to eq(deploy_key.title) - end - - it 'should return 404 Not Found with invalid ID' do - get api("/projects/#{project.id}/deploy_keys/404", user) - expect(response).to have_http_status(404) - end - end - - describe 'POST /projects/:id/deploy_keys' do - it 'should not create an invalid ssh key' do - post api("/projects/#{project.id}/deploy_keys", user), { title: 'invalid key' } - expect(response).to have_http_status(400) - expect(json_response['message']['key']).to eq([ - 'can\'t be blank', - 'is too short (minimum is 0 characters)', - 'is invalid' - ]) - end - - it 'should not create a key without title' do - post api("/projects/#{project.id}/deploy_keys", user), key: 'some key' - expect(response).to have_http_status(400) - expect(json_response['message']['title']).to eq([ - 'can\'t be blank', - 'is too short (minimum is 0 characters)' - ]) - end - - it 'should create new ssh key' do - key_attrs = attributes_for :key - expect do - post api("/projects/#{project.id}/deploy_keys", user), key_attrs - end.to change{ project.deploy_keys.count }.by(1) - end - end - - describe 'DELETE /projects/:id/deploy_keys/:key_id' do - before { deploy_key } - - it 'should delete existing key' do - expect do - delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", user) - end.to change{ project.deploy_keys.count }.by(-1) - end - - it 'should return 404 Not Found with invalid ID' do - delete api("/projects/#{project.id}/deploy_keys/404", user) - expect(response).to have_http_status(404) - end - end - end - + describe :fork_admin do let(:project_fork_target) { create(:project) } let(:project_fork_source) { create(:project, :public) } diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index 1c7c60ec644..cf1e8d9b514 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -98,7 +98,7 @@ describe Ci::API::API do { "key" => "CI_BUILD_TRIGGERED", "value" => "true", "public" => true }, { "key" => "DB_NAME", "value" => "postgres", "public" => true }, { "key" => "SECRET_KEY", "value" => "secret_value", "public" => false }, - { "key" => "TRIGGER_KEY", "value" => "TRIGGER_VALUE", "public" => false } + { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false } ) end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 82ab582beac..8537c252b58 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -75,9 +75,9 @@ describe 'Git HTTP requests', lib: true do context "with correct credentials" do let(:env) { { user: user.username, password: user.password } } - it "uploads get status 200 (because Git hooks do the real check)" do + it "uploads get status 403" do upload(path, env) do |response| - expect(response).to have_http_status(200) + expect(response).to have_http_status(403) end end @@ -86,7 +86,7 @@ describe 'Git HTTP requests', lib: true do allow(Gitlab.config.gitlab_shell).to receive(:receive_pack).and_return(false) upload(path, env) do |response| - expect(response).to have_http_status(404) + expect(response).to have_http_status(403) end end end @@ -236,9 +236,9 @@ describe 'Git HTTP requests', lib: true do end end - it "uploads get status 200 (because Git hooks do the real check)" do + it "uploads get status 404" do upload(path, user: user.username, password: user.password) do |response| - expect(response).to have_http_status(200) + expect(response).to have_http_status(404) end end end @@ -349,19 +349,19 @@ describe 'Git HTTP requests', lib: true do end end - def clone_get(project, options={}) + def clone_get(project, options = {}) get "/#{project}/info/refs", { service: 'git-upload-pack' }, auth_env(*options.values_at(:user, :password, :spnego_request_token)) end - def clone_post(project, options={}) + def clone_post(project, options = {}) post "/#{project}/git-upload-pack", {}, auth_env(*options.values_at(:user, :password, :spnego_request_token)) end - def push_get(project, options={}) + def push_get(project, options = {}) get "/#{project}/info/refs", { service: 'git-receive-pack' }, auth_env(*options.values_at(:user, :password, :spnego_request_token)) end - def push_post(project, options={}) + def push_post(project, options = {}) post "/#{project}/git-receive-pack", {}, auth_env(*options.values_at(:user, :password, :spnego_request_token)) end diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index 8b19936ae6d..69eeb45ed71 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -1,6 +1,5 @@ require 'spec_helper' -# team_update_admin_user PUT /admin/users/:id/team_update(.:format) admin/users#team_update # block_admin_user PUT /admin/users/:id/block(.:format) admin/users#block # unblock_admin_user PUT /admin/users/:id/unblock(.:format) admin/users#unblock # admin_users GET /admin/users(.:format) admin/users#index @@ -11,10 +10,6 @@ require 'spec_helper' # PUT /admin/users/:id(.:format) admin/users#update # DELETE /admin/users/:id(.:format) admin/users#destroy describe Admin::UsersController, "routing" do - it "to #team_update" do - expect(put("/admin/users/1/team_update")).to route_to('admin/users#team_update', id: '1') - end - it "to #block" do expect(put("/admin/users/1/block")).to route_to('admin/users#block', id: '1') end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 620f328a114..b941e78f983 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -135,10 +135,6 @@ describe Projects::RepositoriesController, 'routing' do it 'to #archive format:tar.bz2' do expect(get('/gitlab/gitlabhq/repository/archive.tar.bz2')).to route_to('projects/repositories#archive', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'tar.bz2') end - - it 'to #show' do - expect(get('/gitlab/gitlabhq/repository')).to route_to('projects/repositories#show', namespace_id: 'gitlab', project_id: 'gitlabhq') - end end describe Projects::BranchesController, 'routing' do @@ -483,13 +479,16 @@ end describe Projects::NetworkController, 'routing' do it 'to #show' do expect(get('/gitlab/gitlabhq/network/master')).to route_to('projects/network#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master') - expect(get('/gitlab/gitlabhq/network/master.json')).to route_to('projects/network#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', format: 'json') + expect(get('/gitlab/gitlabhq/network/ends-with.json')).to route_to('projects/network#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'ends-with.json') + expect(get('/gitlab/gitlabhq/network/master?format=json')).to route_to('projects/network#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', format: 'json') end end describe Projects::GraphsController, 'routing' do it 'to #show' do expect(get('/gitlab/gitlabhq/graphs/master')).to route_to('projects/graphs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master') + expect(get('/gitlab/gitlabhq/graphs/ends-with.json')).to route_to('projects/graphs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'ends-with.json') + expect(get('/gitlab/gitlabhq/graphs/master?format=json')).to route_to('projects/graphs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master', format: 'json') end end diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 0a52c1ab933..1d4df9197f6 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -176,18 +176,10 @@ describe Profiles::KeysController, "routing" do expect(post("/profile/keys")).to route_to('profiles/keys#create') end - it "to #edit" do - expect(get("/profile/keys/1/edit")).to route_to('profiles/keys#edit', id: '1') - end - it "to #show" do expect(get("/profile/keys/1")).to route_to('profiles/keys#show', id: '1') end - it "to #update" do - expect(put("/profile/keys/1")).to route_to('profiles/keys#update', id: '1') - end - it "to #destroy" do expect(delete("/profile/keys/1")).to route_to('profiles/keys#destroy', id: '1') end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 47c0580e0f0..ffa998dffc3 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -7,6 +7,7 @@ describe GitPushService, services: true do let(:project) { create :project } before do + project.team << [user, :master] @blankrev = Gitlab::Git::BLANK_SHA @oldrev = sample_commit.parent_id @newrev = sample_commit.id @@ -172,7 +173,7 @@ describe GitPushService, services: true do describe "Push Event" do before do service = execute_service(project, user, @oldrev, @newrev, @ref ) - @event = Event.last + @event = Event.find_by_action(Event::PUSHED) @push_data = service.push_data end @@ -224,8 +225,10 @@ describe GitPushService, services: true do it "when pushing a branch for the first time" do expect(project).to receive(:execute_hooks) expect(project.default_branch).to eq("master") - expect(project.protected_branches).to receive(:create).with({ name: "master", developers_can_push: false, developers_can_merge: false }) execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) + expect(project.protected_branches).not_to be_empty + expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::MASTER) end it "when pushing a branch for the first time with default branch protection disabled" do @@ -233,8 +236,8 @@ describe GitPushService, services: true do expect(project).to receive(:execute_hooks) expect(project.default_branch).to eq("master") - expect(project.protected_branches).not_to receive(:create) execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) + expect(project.protected_branches).to be_empty end it "when pushing a branch for the first time with default branch protection set to 'developers can push'" do @@ -242,9 +245,12 @@ describe GitPushService, services: true do expect(project).to receive(:execute_hooks) expect(project.default_branch).to eq("master") - expect(project.protected_branches).to receive(:create).with({ name: "master", developers_can_push: true, developers_can_merge: false }) - execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master') + execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) + + expect(project.protected_branches).not_to be_empty + expect(project.protected_branches.last.push_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) + expect(project.protected_branches.last.merge_access_level.access_level).to eq(Gitlab::Access::MASTER) end it "when pushing a branch for the first time with default branch protection set to 'developers can merge'" do @@ -252,8 +258,10 @@ describe GitPushService, services: true do expect(project).to receive(:execute_hooks) expect(project.default_branch).to eq("master") - expect(project.protected_branches).to receive(:create).with({ name: "master", developers_can_push: false, developers_can_merge: true }) execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) + expect(project.protected_branches).not_to be_empty + expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) end it "when pushing new commits to existing branch" do diff --git a/spec/services/import_export_clean_up_service_spec.rb b/spec/services/import_export_clean_up_service_spec.rb new file mode 100644 index 00000000000..81b1d327696 --- /dev/null +++ b/spec/services/import_export_clean_up_service_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe ImportExportCleanUpService, services: true do + describe '#execute' do + let(:service) { described_class.new } + + let(:tmp_import_export_folder) { 'tmp/project_exports' } + + context 'when the import/export directory does not exist' do + it 'does not remove any archives' do + path = '/invalid/path/' + stub_repository_downloads_path(path) + + expect(File).to receive(:directory?).with(path + tmp_import_export_folder).and_return(false).at_least(:once) + expect(service).not_to receive(:clean_up_export_files) + + service.execute + end + end + + context 'when the import/export directory exists' do + it 'removes old files' do + in_directory_with_files(mtime: 2.days.ago) do |dir, files| + service.execute + + files.each { |file| expect(File.exist?(file)).to eq false } + expect(File.directory?(dir)).to eq false + end + end + + it 'does not remove new files' do + in_directory_with_files(mtime: 2.hours.ago) do |dir, files| + service.execute + + files.each { |file| expect(File.exist?(file)).to eq true } + expect(File.directory?(dir)).to eq true + end + end + end + + def in_directory_with_files(mtime:) + Dir.mktmpdir do |tmpdir| + stub_repository_downloads_path(tmpdir) + dir = File.join(tmpdir, tmp_import_export_folder, 'subfolder') + FileUtils.mkdir_p(dir) + + files = FileUtils.touch(file_list(dir) + [dir], mtime: mtime.to_time) + + yield(dir, files) + end + end + + def stub_repository_downloads_path(path) + new_shared_settings = Settings.shared.merge('path' => path) + allow(Settings).to receive(:shared).and_return(new_shared_settings) + end + + def file_list(dir) + Array.new(5) do |num| + File.join(dir, "random-#{num}.tar.gz") + end + end + end +end diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index 782d74ec5ec..232508cda23 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -61,7 +61,7 @@ describe MergeRequests::BuildService, services: true do end context 'one commit in the diff' do - let(:commits) { [commit_1] } + let(:commits) { Commit.decorate([commit_1], project) } it 'allows the merge request to be created' do expect(merge_request.can_be_created).to eq(true) @@ -84,7 +84,7 @@ describe MergeRequests::BuildService, services: true do end context 'commit has no description' do - let(:commits) { [commit_2] } + let(:commits) { Commit.decorate([commit_2], project) } it 'uses the title of the commit as the title of the merge request' do expect(merge_request.title).to eq(commit_2.safe_message) @@ -111,7 +111,7 @@ describe MergeRequests::BuildService, services: true do end context 'commit has no description' do - let(:commits) { [commit_2] } + let(:commits) { Commit.decorate([commit_2], project) } it 'sets the description to "Closes #$issue-iid"' do expect(merge_request.description).to eq("Closes ##{issue.iid}") @@ -121,7 +121,7 @@ describe MergeRequests::BuildService, services: true do end context 'more than one commit in the diff' do - let(:commits) { [commit_1, commit_2] } + let(:commits) { Commit.decorate([commit_1, commit_2], project) } it 'allows the merge request to be created' do expect(merge_request.can_be_created).to eq(true) 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 new file mode 100644 index 00000000000..c4b87468275 --- /dev/null +++ b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe MergeRequests::MergeRequestDiffCacheService do + let(:subject) { MergeRequests::MergeRequestDiffCacheService.new } + + describe '#execute' do + it 'retrieves the diff files to cache the highlighted result' do + merge_request = create(:merge_request) + cache_key = [merge_request.merge_request_diff, 'highlighted-diff-files', Gitlab::Diff::FileCollection::MergeRequest.default_options] + + expect(Rails.cache).to receive(:read).with(cache_key).and_return({}) + expect(Rails.cache).to receive(:write).with(cache_key, anything) + + subject.execute(merge_request) + end + end +end diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index f5bf3c1e367..8ffebcac698 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -75,6 +75,17 @@ describe MergeRequests::MergeService, services: true do expect(merge_request.merge_error).to eq("error") end + + it 'aborts if there is a merge conflict' do + allow_any_instance_of(Repository).to receive(:merge).and_return(false) + allow(service).to receive(:execute_hooks) + + service.execute(merge_request) + + expect(merge_request.open?).to be_truthy + expect(merge_request.merge_commit_sha).to be_nil + expect(merge_request.merge_error).to eq("Conflicts detected during merge") + end end end end diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index ce643b3f860..781ee7ffed3 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -57,7 +57,7 @@ describe MergeRequests::RefreshService, services: true do it 'should execute hooks with update action' do expect(refresh_service).to have_received(:execute_hooks). - with(@merge_request, 'update') + with(@merge_request, 'update', @oldrev) end it { expect(@merge_request.notes).not_to be_empty } @@ -113,7 +113,7 @@ describe MergeRequests::RefreshService, services: true do it 'should execute hooks with update action' do expect(refresh_service).to have_received(:execute_hooks). - with(@fork_merge_request, 'update') + with(@fork_merge_request, 'update', @oldrev) end it { expect(@merge_request.notes).to be_empty } @@ -158,7 +158,7 @@ describe MergeRequests::RefreshService, services: true do it 'refreshes the merge request' do expect(refresh_service).to receive(:execute_hooks). - with(@fork_merge_request, 'update') + with(@fork_merge_request, 'update', Gitlab::Git::BLANK_SHA) allow_any_instance_of(Repository).to receive(:merge_base).and_return(@oldrev) refresh_service.execute(Gitlab::Git::BLANK_SHA, @newrev, 'refs/heads/master') diff --git a/spec/services/projects/enable_deploy_key_service_spec.rb b/spec/services/projects/enable_deploy_key_service_spec.rb new file mode 100644 index 00000000000..a37510cf159 --- /dev/null +++ b/spec/services/projects/enable_deploy_key_service_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Projects::EnableDeployKeyService, services: true do + let(:deploy_key) { create(:deploy_key, public: true) } + let(:project) { create(:empty_project) } + let(:user) { project.creator} + let!(:params) { { key_id: deploy_key.id } } + + it 'enables the key' do + expect do + service.execute + end.to change { project.deploy_keys.count }.from(0).to(1) + end + + context 'trying to add an unaccessable key' do + let(:another_key) { create(:another_key) } + let!(:params) { { key_id: another_key.id } } + + it 'returns nil if the key cannot be added' do + expect(service.execute).to be nil + end + end + + def service + Projects::EnableDeployKeyService.new(project, user, params) + end +end diff --git a/spec/simplecov_env.rb b/spec/simplecov_env.rb new file mode 100644 index 00000000000..6f8f7109e14 --- /dev/null +++ b/spec/simplecov_env.rb @@ -0,0 +1,54 @@ +require 'simplecov' + +module SimpleCovEnv + extend self + + def start! + return unless ENV['SIMPLECOV'] + + configure_profile + configure_job + + SimpleCov.start + end + + def configure_job + SimpleCov.configure do + if ENV['CI_BUILD_NAME'] + coverage_dir "coverage/#{ENV['CI_BUILD_NAME']}" + command_name ENV['CI_BUILD_NAME'] + end + + if ENV['CI'] + SimpleCov.at_exit do + # In CI environment don't generate formatted reports + # Only generate .resultset.json + SimpleCov.result + end + end + end + end + + def configure_profile + SimpleCov.configure do + load_profile 'test_frameworks' + track_files '{app,lib}/**/*.rb' + + add_filter '/vendor/ruby/' + add_filter 'config/initializers/' + + add_group 'Controllers', 'app/controllers' + add_group 'Models', 'app/models' + add_group 'Mailers', 'app/mailers' + add_group 'Helpers', 'app/helpers' + add_group 'Workers', %w(app/jobs app/workers) + add_group 'Libraries', 'lib' + add_group 'Services', 'app/services' + add_group 'Finders', 'app/finders' + add_group 'Uploaders', 'app/uploaders' + add_group 'Validators', 'app/validators' + + merge_timeout 7200 + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3638dcbb2d3..4f3aacf55be 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,5 @@ -if ENV['SIMPLECOV'] - require 'simplecov' - SimpleCov.start :rails -end +require './spec/simplecov_env' +SimpleCovEnv.start! ENV["RAILS_ENV"] ||= 'test' diff --git a/spec/support/issue_helpers.rb b/spec/support/issue_helpers.rb new file mode 100644 index 00000000000..85241793743 --- /dev/null +++ b/spec/support/issue_helpers.rb @@ -0,0 +1,13 @@ +module IssueHelpers + def visit_issues(project, opts = {}) + visit namespace_project_issues_path project.namespace, project, opts + end + + def first_issue + page.all('ul.issues-list > li').first.text + end + + def last_issue + page.all('ul.issues-list > li').last.text + end +end diff --git a/spec/support/merge_request_helpers.rb b/spec/support/merge_request_helpers.rb new file mode 100644 index 00000000000..d5801c8272f --- /dev/null +++ b/spec/support/merge_request_helpers.rb @@ -0,0 +1,13 @@ +module MergeRequestHelpers + def visit_merge_requests(project, opts = {}) + visit namespace_project_merge_requests_path project.namespace, project, opts + end + + def first_merge_request + page.all('ul.mr-list > li').first.text + end + + def last_merge_request + page.all('ul.mr-list > li').last.text + end +end diff --git a/spec/support/select2_helper.rb b/spec/support/select2_helper.rb index 04d25b5e9e9..35cc51725c6 100644 --- a/spec/support/select2_helper.rb +++ b/spec/support/select2_helper.rb @@ -11,7 +11,7 @@ # module Select2Helper - def select2(value, options={}) + def select2(value, options = {}) raise ArgumentError, 'options must be a Hash' unless options.kind_of?(Hash) selector = options.fetch(:from) diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 83f2ad96fd8..1c0c66969e3 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -6,6 +6,7 @@ module TestEnv # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { 'empty-branch' => '7efb185', + 'ends-with.json' => '98b0d8b3', 'flatten-dir' => 'e56497b', 'feature' => '0b4bc9a', 'feature_conflict' => 'bb5206f', @@ -20,7 +21,9 @@ module TestEnv 'gitattributes' => '5a62481', 'expand-collapse-diffs' => '4842455', 'expand-collapse-files' => '025db92', - 'expand-collapse-lines' => '238e82d' + 'expand-collapse-lines' => '238e82d', + 'video' => '8879059', + 'crlf-diff' => '5938907' } # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily diff --git a/spec/teaspoon_env.rb b/spec/teaspoon_env.rb index 69b2b9b6d5b..1a3bbb9c8cc 100644 --- a/spec/teaspoon_env.rb +++ b/spec/teaspoon_env.rb @@ -38,7 +38,7 @@ Teaspoon.configure do |config| # Specify a file matcher as a regular expression and all matching files will be loaded when the suite is run. These # files need to be within an asset path. You can add asset paths using the `config.asset_paths`. - suite.matcher = "{spec/javascripts,app/assets}/**/*_spec.{js,js.coffee,coffee}" + suite.matcher = "{spec/javascripts,app/assets}/**/*_spec.{js,js.es6,es6}" # Load additional JS files, but requiring them in your spec helper is the preferred way to do this. # suite.javascripts = [] diff --git a/spec/views/admin/dashboard/index.html.haml_spec.rb b/spec/views/admin/dashboard/index.html.haml_spec.rb new file mode 100644 index 00000000000..dae858a52f6 --- /dev/null +++ b/spec/views/admin/dashboard/index.html.haml_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe 'admin/dashboard/index.html.haml' do + include Devise::TestHelpers + + before do + assign(:projects, create_list(:empty_project, 1)) + assign(:users, create_list(:user, 1)) + assign(:groups, create_list(:group, 1)) + + allow(view).to receive(:admin?).and_return(true) + end + + it "shows version of GitLab Workhorse" do + render + + expect(rendered).to have_content 'GitLab Workhorse' + expect(rendered).to have_content Gitlab::Workhorse.version + end +end diff --git a/spec/views/devise/shared/_signin_box.html.haml_spec.rb b/spec/views/devise/shared/_signin_box.html.haml_spec.rb index 05a76ee4bdb..ee362e6fcb3 100644 --- a/spec/views/devise/shared/_signin_box.html.haml_spec.rb +++ b/spec/views/devise/shared/_signin_box.html.haml_spec.rb @@ -31,7 +31,7 @@ describe 'devise/shared/_signin_box' do def enable_crowd allow(view).to receive(:form_based_providers).and_return([:crowd]) allow(view).to receive(:crowd_enabled?).and_return(true) - allow(view).to receive(:user_omniauth_authorize_path).with('crowd'). + allow(view).to receive(:omniauth_authorize_path).with(:user, :crowd). and_return('/crowd') end end diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/builds/show.html.haml_spec.rb index 42220a20c75..464051063d8 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/builds/show.html.haml_spec.rb @@ -44,9 +44,29 @@ describe 'projects/builds/show' do it 'shows commit title and not show commit message' do render - + expect(rendered).to have_css('p.build-light-text.append-bottom-0', text: /\A\n#{Regexp.escape(commit_title)}\n\Z/) end end + + describe 'shows trigger variables in sidebar' do + let(:trigger_request) { create(:ci_trigger_request_with_variables, pipeline: pipeline) } + + before do + build.trigger_request = trigger_request + render + end + + it 'shows trigger variables in separate lines' do + expect(rendered).to have_css('code', text: variable_regexp('TRIGGER_KEY_1', 'TRIGGER_VALUE_1')) + expect(rendered).to have_css('code', text: variable_regexp('TRIGGER_KEY_2', 'TRIGGER_VALUE_2')) + end + end + + private + + def variable_regexp(key, value) + /\A#{Regexp.escape("#{key}=#{value}")}\Z/ + end end diff --git a/spec/views/projects/issues/_related_branches.html.haml_spec.rb b/spec/views/projects/issues/_related_branches.html.haml_spec.rb new file mode 100644 index 00000000000..78af61f15a7 --- /dev/null +++ b/spec/views/projects/issues/_related_branches.html.haml_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe 'projects/issues/_related_branches' do + include Devise::TestHelpers + + let(:project) { create(:project) } + let(:branch) { project.repository.find_branch('feature') } + let!(:pipeline) { create(:ci_pipeline, project: project, sha: branch.target.id, ref: 'feature') } + + before do + assign(:project, project) + assign(:related_branches, ['feature']) + + render + end + + it 'shows the related branches with their build status' do + expect(rendered).to match('feature') + expect(rendered).to have_css('.related-branch-ci-status') + end +end diff --git a/spec/views/projects/tree/show.html.haml_spec.rb b/spec/views/projects/tree/show.html.haml_spec.rb new file mode 100644 index 00000000000..0f3fc1ee1ac --- /dev/null +++ b/spec/views/projects/tree/show.html.haml_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe 'projects/tree/show' do + include Devise::TestHelpers + + let(:project) { create(:project) } + let(:repository) { project.repository } + + before do + assign(:project, project) + assign(:repository, repository) + + allow(view).to receive(:can?).and_return(true) + allow(view).to receive(:can_collaborate_with_project?).and_return(true) + end + + context 'for branch names ending on .json' do + let(:ref) { 'ends-with.json' } + let(:commit) { repository.commit(ref) } + let(:path) { '' } + let(:tree) { repository.tree(commit.id, path) } + + before do + assign(:ref, ref) + assign(:commit, commit) + assign(:id, commit.id) + assign(:tree, tree) + assign(:path, path) + end + + it 'displays correctly' do + render + expect(rendered).to have_css('.js-project-refs-dropdown .dropdown-toggle-text', text: ref) + expect(rendered).to have_css('.readme-holder .file-content', text: ref) + end + end +end diff --git a/spec/workers/email_receiver_worker_spec.rb b/spec/workers/email_receiver_worker_spec.rb index de40a6f78af..fe70501eeac 100644 --- a/spec/workers/email_receiver_worker_spec.rb +++ b/spec/workers/email_receiver_worker_spec.rb @@ -17,7 +17,7 @@ describe EmailReceiverWorker do context "when an error occurs" do before do - allow_any_instance_of(Gitlab::Email::Receiver).to receive(:execute).and_raise(Gitlab::Email::Receiver::EmptyEmailError) + allow_any_instance_of(Gitlab::Email::Receiver).to receive(:execute).and_raise(Gitlab::Email::EmptyEmailError) end it "sends out a rejection email" do diff --git a/spec/workers/project_destroy_worker_spec.rb b/spec/workers/project_destroy_worker_spec.rb new file mode 100644 index 00000000000..1b910d9b91e --- /dev/null +++ b/spec/workers/project_destroy_worker_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe ProjectDestroyWorker do + let(:project) { create(:project) } + let(:path) { project.repository.path_to_repo } + + subject { ProjectDestroyWorker.new } + + describe "#perform" do + it "deletes the project" do + subject.perform(project.id, project.owner, {}) + + expect(Project.all).not_to include(project) + expect(Dir.exist?(path)).to be_falsey + end + + it "deletes the project but skips repo deletion" do + subject.perform(project.id, project.owner, { "skip_repo" => true }) + + expect(Project.all).not_to include(project) + expect(Dir.exist?(path)).to be_truthy + end + end +end diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index 5f762282b5e..60605460adb 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -14,21 +14,24 @@ describe RepositoryForkWorker do describe "#perform" do it "creates a new repository from a fork" do expect(shell).to receive(:fork_repository).with( - project.repository_storage_path, + '/test/path', project.path_with_namespace, + project.repository_storage_path, fork_project.namespace.path ).and_return(true) subject.perform( project.id, + '/test/path', project.path_with_namespace, fork_project.namespace.path) end it 'flushes various caches' do expect(shell).to receive(:fork_repository).with( - project.repository_storage_path, + '/test/path', project.path_with_namespace, + project.repository_storage_path, fork_project.namespace.path ).and_return(true) @@ -38,7 +41,7 @@ describe RepositoryForkWorker do expect_any_instance_of(Repository).to receive(:expire_exists_cache). and_call_original - subject.perform(project.id, project.path_with_namespace, + subject.perform(project.id, '/test/path', project.path_with_namespace, fork_project.namespace.path) end @@ -49,6 +52,7 @@ describe RepositoryForkWorker do subject.perform( project.id, + '/test/path', project.path_with_namespace, fork_project.namespace.path) end diff --git a/vendor/assets/javascripts/task_list.js b/vendor/assets/javascripts/task_list.js new file mode 100644 index 00000000000..bc451506b6a --- /dev/null +++ b/vendor/assets/javascripts/task_list.js @@ -0,0 +1,119 @@ + +/*= provides tasklist:enabled */ + + +/*= provides tasklist:disabled */ + + +/*= provides tasklist:change */ + + +/*= provides tasklist:changed */ + +(function() { + var codeFencesPattern, complete, completePattern, disableTaskList, disableTaskLists, enableTaskList, enableTaskLists, escapePattern, incomplete, incompletePattern, itemPattern, itemsInParasPattern, updateTaskList, updateTaskListItem, + indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; + + incomplete = "[ ]"; + + complete = "[x]"; + + escapePattern = function(str) { + return str.replace(/([\[\]])/g, "\\$1").replace(/\s/, "\\s").replace("x", "[xX]"); + }; + + incompletePattern = RegExp("" + (escapePattern(incomplete))); + + completePattern = RegExp("" + (escapePattern(complete))); + + itemPattern = RegExp("^(?:\\s*(?:>\\s*)*(?:[-+*]|(?:\\d+\\.)))\\s*(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ")\\s+(?!\\(.*?\\))(?=(?:\\[.*?\\]\\s*(?:\\[.*?\\]|\\(.*?\\))\\s*)*(?:[^\\[]|$))"); + + codeFencesPattern = /^`{3}(?:\s*\w+)?[\S\s].*[\S\s]^`{3}$/mg; + + itemsInParasPattern = RegExp("^(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ").+$", "g"); + + updateTaskListItem = function(source, itemIndex, checked) { + var clean, index, line, result; + clean = source.replace(/\r/g, '').replace(codeFencesPattern, '').replace(itemsInParasPattern, '').split("\n"); + index = 0; + result = (function() { + var i, len, ref, results; + ref = source.split("\n"); + results = []; + for (i = 0, len = ref.length; i < len; i++) { + line = ref[i]; + if (indexOf.call(clean, line) >= 0 && line.match(itemPattern)) { + index += 1; + if (index === itemIndex) { + line = checked ? line.replace(incompletePattern, complete) : line.replace(completePattern, incomplete); + } + } + results.push(line); + } + return results; + })(); + return result.join("\n"); + }; + + updateTaskList = function($item) { + var $container, $field, checked, event, index; + $container = $item.closest('.js-task-list-container'); + $field = $container.find('.js-task-list-field'); + index = 1 + $container.find('.task-list-item-checkbox').index($item); + checked = $item.prop('checked'); + event = $.Event('tasklist:change'); + $field.trigger(event, [index, checked]); + if (!event.isDefaultPrevented()) { + $field.val(updateTaskListItem($field.val(), index, checked)); + $field.trigger('change'); + return $field.trigger('tasklist:changed', [index, checked]); + } + }; + + $(document).on('change', '.task-list-item-checkbox', function() { + return updateTaskList($(this)); + }); + + enableTaskList = function($container) { + if ($container.find('.js-task-list-field').length > 0) { + $container.find('.task-list-item').addClass('enabled').find('.task-list-item-checkbox').attr('disabled', null); + return $container.addClass('is-task-list-enabled').trigger('tasklist:enabled'); + } + }; + + enableTaskLists = function($containers) { + var container, i, len, results; + results = []; + for (i = 0, len = $containers.length; i < len; i++) { + container = $containers[i]; + results.push(enableTaskList($(container))); + } + return results; + }; + + disableTaskList = function($container) { + $container.find('.task-list-item').removeClass('enabled').find('.task-list-item-checkbox').attr('disabled', 'disabled'); + return $container.removeClass('is-task-list-enabled').trigger('tasklist:disabled'); + }; + + disableTaskLists = function($containers) { + var container, i, len, results; + results = []; + for (i = 0, len = $containers.length; i < len; i++) { + container = $containers[i]; + results.push(disableTaskList($(container))); + } + return results; + }; + + $.fn.taskList = function(method) { + var $container, methods; + $container = $(this).closest('.js-task-list-container'); + methods = { + enable: enableTaskLists, + disable: disableTaskLists + }; + return methods[method || 'enable']($container); + }; + +}).call(this); diff --git a/vendor/assets/javascripts/task_list.js.coffee b/vendor/assets/javascripts/task_list.js.coffee deleted file mode 100644 index 584751af8ea..00000000000 --- a/vendor/assets/javascripts/task_list.js.coffee +++ /dev/null @@ -1,258 +0,0 @@ -# The MIT License (MIT) -# -# Copyright (c) 2014 GitHub, Inc. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -# TaskList Behavior -# -#= provides tasklist:enabled -#= provides tasklist:disabled -#= provides tasklist:change -#= provides tasklist:changed -# -# -# Enables Task List update behavior. -# -# ### Example Markup -# -# <div class="js-task-list-container"> -# <ul class="task-list"> -# <li class="task-list-item"> -# <input type="checkbox" class="js-task-list-item-checkbox" disabled /> -# text -# </li> -# </ul> -# <form> -# <textarea class="js-task-list-field">- [ ] text</textarea> -# </form> -# </div> -# -# ### Specification -# -# TaskLists MUST be contained in a `(div).js-task-list-container`. -# -# TaskList Items SHOULD be an a list (`UL`/`OL`) element. -# -# Task list items MUST match `(input).task-list-item-checkbox` and MUST be -# `disabled` by default. -# -# TaskLists MUST have a `(textarea).js-task-list-field` form element whose -# `value` attribute is the source (Markdown) to be udpated. The source MUST -# follow the syntax guidelines. -# -# TaskList updates trigger `tasklist:change` events. If the change is -# successful, `tasklist:changed` is fired. The change can be canceled. -# -# jQuery is required. -# -# ### Methods -# -# `.taskList('enable')` or `.taskList()` -# -# Enables TaskList updates for the container. -# -# `.taskList('disable')` -# -# Disables TaskList updates for the container. -# -## ### Events -# -# `tasklist:enabled` -# -# Fired when the TaskList is enabled. -# -# * **Synchronicity** Sync -# * **Bubbles** Yes -# * **Cancelable** No -# * **Target** `.js-task-list-container` -# -# `tasklist:disabled` -# -# Fired when the TaskList is disabled. -# -# * **Synchronicity** Sync -# * **Bubbles** Yes -# * **Cancelable** No -# * **Target** `.js-task-list-container` -# -# `tasklist:change` -# -# Fired before the TaskList item change takes affect. -# -# * **Synchronicity** Sync -# * **Bubbles** Yes -# * **Cancelable** Yes -# * **Target** `.js-task-list-field` -# -# `tasklist:changed` -# -# Fired once the TaskList item change has taken affect. -# -# * **Synchronicity** Sync -# * **Bubbles** Yes -# * **Cancelable** No -# * **Target** `.js-task-list-field` -# -# ### NOTE -# -# Task list checkboxes are rendered as disabled by default because rendered -# user content is cached without regard for the viewer. - -incomplete = "[ ]" -complete = "[x]" - -# Escapes the String for regular expression matching. -escapePattern = (str) -> - str. - replace(/([\[\]])/g, "\\$1"). # escape square brackets - replace(/\s/, "\\s"). # match all white space - replace("x", "[xX]") # match all cases - -incompletePattern = /// - #{escapePattern(incomplete)} -/// -completePattern = /// - #{escapePattern(complete)} -/// - -# Pattern used to identify all task list items. -# Useful when you need iterate over all items. -itemPattern = /// - ^ - (?: # prefix, consisting of - \s* # optional leading whitespace - (?:>\s*)* # zero or more blockquotes - (?:[-+*]|(?:\d+\.)) # list item indicator - ) - \s* # optional whitespace prefix - ( # checkbox - #{escapePattern(complete)}| - #{escapePattern(incomplete)} - ) - \s+ # is followed by whitespace - (?! - \(.*?\) # is not part of a [foo](url) link - ) - (?= # and is followed by zero or more links - (?:\[.*?\]\s*(?:\[.*?\]|\(.*?\))\s*)* - (?:[^\[]|$) # and either a non-link or the end of the string - ) -/// - -# Used to filter out code fences from the source for comparison only. -# http://rubular.com/r/x5EwZVrloI -# Modified slightly due to issues with JS -codeFencesPattern = /// - ^`{3} # ``` - (?:\s*\w+)? # followed by optional language - [\S\s] # whitespace - .* # code - [\S\s] # whitespace - ^`{3}$ # ``` -///mg - -# Used to filter out potential mismatches (items not in lists). -# http://rubular.com/r/OInl6CiePy -itemsInParasPattern = /// - ^ - ( - #{escapePattern(complete)}| - #{escapePattern(incomplete)} - ) - .+ - $ -///g - -# Given the source text, updates the appropriate task list item to match the -# given checked value. -# -# Returns the updated String text. -updateTaskListItem = (source, itemIndex, checked) -> - clean = source.replace(/\r/g, '').replace(codeFencesPattern, ''). - replace(itemsInParasPattern, '').split("\n") - index = 0 - result = for line in source.split("\n") - if line in clean && line.match(itemPattern) - index += 1 - if index == itemIndex - line = - if checked - line.replace(incompletePattern, complete) - else - line.replace(completePattern, incomplete) - line - result.join("\n") - -# Updates the $field value to reflect the state of $item. -# Triggers the `tasklist:change` event before the value has changed, and fires -# a `tasklist:changed` event once the value has changed. -updateTaskList = ($item) -> - $container = $item.closest '.js-task-list-container' - $field = $container.find '.js-task-list-field' - index = 1 + $container.find('.task-list-item-checkbox').index($item) - checked = $item.prop 'checked' - - event = $.Event 'tasklist:change' - $field.trigger event, [index, checked] - - unless event.isDefaultPrevented() - $field.val updateTaskListItem($field.val(), index, checked) - $field.trigger 'change' - $field.trigger 'tasklist:changed', [index, checked] - -# When the task list item checkbox is updated, submit the change -$(document).on 'change', '.task-list-item-checkbox', -> - updateTaskList $(this) - -# Enables TaskList item changes. -enableTaskList = ($container) -> - if $container.find('.js-task-list-field').length > 0 - $container. - find('.task-list-item').addClass('enabled'). - find('.task-list-item-checkbox').attr('disabled', null) - $container.addClass('is-task-list-enabled'). - trigger 'tasklist:enabled' - -# Enables a collection of TaskList containers. -enableTaskLists = ($containers) -> - for container in $containers - enableTaskList $(container) - -# Disable TaskList item changes. -disableTaskList = ($container) -> - $container. - find('.task-list-item').removeClass('enabled'). - find('.task-list-item-checkbox').attr('disabled', 'disabled') - $container.removeClass('is-task-list-enabled'). - trigger 'tasklist:disabled' - -# Disables a collection of TaskList containers. -disableTaskLists = ($containers) -> - for container in $containers - disableTaskList $(container) - -$.fn.taskList = (method) -> - $container = $(this).closest('.js-task-list-container') - - methods = - enable: enableTaskLists - disable: disableTaskLists - - methods[method || 'enable']($container) diff --git a/vendor/gitignore/Elm.gitignore b/vendor/gitignore/Elm.gitignore index a594364e2c0..8b631e7de00 100644 --- a/vendor/gitignore/Elm.gitignore +++ b/vendor/gitignore/Elm.gitignore @@ -1,4 +1,4 @@ # elm-package generated files -elm-stuff/ +elm-stuff # elm-repl generated files repl-temp-* diff --git a/vendor/gitignore/Global/VisualStudioCode.gitignore b/vendor/gitignore/Global/VisualStudioCode.gitignore index faa18382a3c..d9960081c98 100644 --- a/vendor/gitignore/Global/VisualStudioCode.gitignore +++ b/vendor/gitignore/Global/VisualStudioCode.gitignore @@ -1,2 +1,4 @@ -.vscode - +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json diff --git a/vendor/gitignore/Go.gitignore b/vendor/gitignore/Go.gitignore index daf913b1b34..cd0d5d1e2f4 100644 --- a/vendor/gitignore/Go.gitignore +++ b/vendor/gitignore/Go.gitignore @@ -22,3 +22,6 @@ _testmain.go *.exe *.test *.prof + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out diff --git a/vendor/gitignore/Leiningen.gitignore b/vendor/gitignore/Leiningen.gitignore index 47fed6c20d9..a9fe6fba80d 100644 --- a/vendor/gitignore/Leiningen.gitignore +++ b/vendor/gitignore/Leiningen.gitignore @@ -1,6 +1,7 @@ pom.xml pom.xml.asc -*jar +*.jar +*.class /lib/ /classes/ /target/ diff --git a/vendor/gitignore/Objective-C.gitignore b/vendor/gitignore/Objective-C.gitignore index 86f21d8e0ff..20592083931 100644 --- a/vendor/gitignore/Objective-C.gitignore +++ b/vendor/gitignore/Objective-C.gitignore @@ -52,7 +52,7 @@ Carthage/Build fastlane/report.xml fastlane/screenshots -#Code Injection +# Code Injection # # After new code Injection tools there's a generated folder /iOSInjectionProject # https://github.com/johnno1962/injectionforxcode diff --git a/vendor/gitignore/Scala.gitignore b/vendor/gitignore/Scala.gitignore index c58d83b3189..a02d882cb88 100644 --- a/vendor/gitignore/Scala.gitignore +++ b/vendor/gitignore/Scala.gitignore @@ -15,3 +15,7 @@ project/plugins/project/ # Scala-IDE specific .scala_dependencies .worksheet + +# ENSIME specific +.ensime_cache/ +.ensime diff --git a/vendor/gitignore/SugarCRM.gitignore b/vendor/gitignore/SugarCRM.gitignore index 842c3ec518b..e9270205fd5 100644 --- a/vendor/gitignore/SugarCRM.gitignore +++ b/vendor/gitignore/SugarCRM.gitignore @@ -7,6 +7,7 @@ # For development the cache directory can be safely ignored and # therefore it is ignored. /cache/ +!/cache/index.html # Ignore some files and directories from the custom directory. /custom/history/ /custom/modulebuilder/ @@ -22,4 +23,5 @@ *.log # Ignore the new upload directories. /upload/ +!/upload/index.html /upload_backup/ diff --git a/vendor/gitignore/TeX.gitignore b/vendor/gitignore/TeX.gitignore index 3cb097c9d5e..34f999df3e7 100644 --- a/vendor/gitignore/TeX.gitignore +++ b/vendor/gitignore/TeX.gitignore @@ -19,6 +19,9 @@ # *.eps # *.pdf +## Generated if empty string is given at "Please type another file name for output:" +.pdf + ## Bibliography auxiliary files (bibtex/biblatex/biber): *.bbl *.bcf @@ -31,6 +34,7 @@ ## Build tool auxiliary files: *.fdb_latexmk *.synctex +*.synctex(busy) *.synctex.gz *.synctex.gz(busy) *.pdfsync @@ -84,6 +88,10 @@ acs-*.bib # gnuplottex *-gnuplottex-* +# gregoriotex +*.gaux +*.gtex + # hyperref *.brf @@ -128,6 +136,9 @@ _minted* *.sagetex.py *.sagetex.scmd +# scrwfile +*.wrt + # sympy *.sout *.sympy diff --git a/vendor/gitignore/Terraform.gitignore b/vendor/gitignore/Terraform.gitignore index 7868d16d216..41859c81f1c 100644 --- a/vendor/gitignore/Terraform.gitignore +++ b/vendor/gitignore/Terraform.gitignore @@ -1,3 +1,6 @@ # Compiled files *.tfstate *.tfstate.backup + +# Module directory +.terraform/ diff --git a/vendor/gitignore/Unity.gitignore b/vendor/gitignore/Unity.gitignore index 5aafcbb7f1d..1c10388911b 100644 --- a/vendor/gitignore/Unity.gitignore +++ b/vendor/gitignore/Unity.gitignore @@ -5,8 +5,9 @@ /[Bb]uilds/ /Assets/AssetStoreTools* -# Autogenerated VS/MD solution and project files +# Autogenerated VS/MD/Consulo solution and project files ExportedObj/ +.consulo/ *.csproj *.unityproj *.sln diff --git a/vendor/gitlab-ci-yml/C++.gitlab-ci.yml b/vendor/gitlab-ci-yml/C++.gitlab-ci.yml new file mode 100644 index 00000000000..c83c49d8c95 --- /dev/null +++ b/vendor/gitlab-ci-yml/C++.gitlab-ci.yml @@ -0,0 +1,26 @@ +# use the official gcc image, based on debian +# can use verions as well, like gcc:5.2 +# see https://hub.docker.com/_/gcc/ +image: gcc + +build: + stage: build + # instead of calling g++ directly you can also use some build toolkit like make + # install the necessary build tools when needed + # before_script: + # - apt update && apt -y install make autoconf + script: + - g++ helloworld.cpp -o mybinary + artifacts: + paths: + - mybinary + # depending on your build setup it's most likely a good idea to cache outputs to reduce the build time + # cache: + # paths: + # - "*.o" + +# run tests using the binary built before +test: + stage: test + script: + - ./runmytests.sh diff --git a/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml b/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml index 0b329aaf1c4..00f9541e89b 100644 --- a/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml @@ -2,7 +2,7 @@ # The image already has Hex installed. You might want to consider to use `elixir:latest` image: trenpixster/elixir:latest -# Pic zero or more services to be used on all builds. +# Pick zero or more services to be used on all builds. # Only needed when using a docker container to run your tests in. # Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service services: diff --git a/vendor/gitlab-ci-yml/Grails.gitlab-ci.yml b/vendor/gitlab-ci-yml/Grails.gitlab-ci.yml new file mode 100644 index 00000000000..7fc698d50cf --- /dev/null +++ b/vendor/gitlab-ci-yml/Grails.gitlab-ci.yml @@ -0,0 +1,40 @@ +# This template uses the java:8 docker image because there isn't any +# official Grails image at this moment +# +# Grails Framework https://grails.org/ is a powerful Groovy-based web application framework for the JVM +# +# This yml works with Grails 3.x only +# Feel free to change GRAILS_VERSION version with your project version (3.0.1, 3.1.1,...) +# Feel free to change GRADLE_VERSION version with your gradle project version (2.13, 2.14,...) +# If you use Angular profile, this yml it's prepared to work with it + +image: java:8 + +variables: + GRAILS_VERSION: "3.1.9" + GRADLE_VERSION: "2.13" + +# We use SDKMan as tool for managing versions +before_script: + - apt-get update -qq && apt-get install -y -qq unzip + - curl -sSL https://get.sdkman.io | bash + - echo sdkman_auto_answer=true > /root/.sdkman/etc/config + - source /root/.sdkman/bin/sdkman-init.sh + - sdk install gradle $GRADLE_VERSION < /dev/null + - sdk use gradle $GRADLE_VERSION +# As it's not a good idea to version gradle.properties feel free to add your +# environments variable here + - echo grailsVersion=$GRAILS_VERSION > gradle.properties + - echo gradleWrapperVersion=2.14 >> gradle.properties +# refresh dependencies from your project + - ./gradlew --refresh-dependencies +# Be aware that if you are using Angular profile, +# Bower cannot be run as root if you don't allow it before. +# Feel free to remove next line if you are not using Bower + - echo {\"allow_root\":true} > /root/.bowerrc + +# This build job does the full grails pipeline +# (compile, test, integrationTest, war, assemble). +build: + script: + - ./gradlew build
\ No newline at end of file diff --git a/vendor/gitlab-ci-yml/LaTeX.gitlab-ci.yml b/vendor/gitlab-ci-yml/LaTeX.gitlab-ci.yml new file mode 100644 index 00000000000..a4aed36889e --- /dev/null +++ b/vendor/gitlab-ci-yml/LaTeX.gitlab-ci.yml @@ -0,0 +1,11 @@ +# use docker image with latex preinstalled +# since there is no official latex image, use https://github.com/blang/latex-docker +# possible alternative: https://github.com/natlownes/docker-latex +image: blang/latex + +build: + script: + - latexmk -pdf + artifacts: + paths: + - "*.pdf" diff --git a/vendor/gitlab-ci-yml/Pages/Hexo.gitlab-ci.yml b/vendor/gitlab-ci-yml/Pages/Hexo.gitlab-ci.yml index b468d79bcad..908463c9d12 100644 --- a/vendor/gitlab-ci-yml/Pages/Hexo.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Pages/Hexo.gitlab-ci.yml @@ -1,25 +1,17 @@ # Full project: https://gitlab.com/pages/hexo -image: python:2.7 - -cache: - paths: - - vendor/ - -test: - stage: test - script: - - pip install hyde - - hyde gen - except: - - master +image: node:4.2.2 pages: - stage: deploy + cache: + paths: + - node_modules/ + script: - - pip install hyde - - hyde gen -d public + - npm install hexo-cli -g + - npm install + - hexo deploy artifacts: paths: - public only: - - master + - master diff --git a/vendor/gitlab-ci-yml/Pages/JBake.gitlab-ci.yml b/vendor/gitlab-ci-yml/Pages/JBake.gitlab-ci.yml new file mode 100644 index 00000000000..bc36a4e6966 --- /dev/null +++ b/vendor/gitlab-ci-yml/Pages/JBake.gitlab-ci.yml @@ -0,0 +1,32 @@ +# This template uses the java:8 docker image because there isn't any +# official JBake image at this moment +# +# JBake https://jbake.org/ is a Java based, open source, static site/blog generator for developers & designers +# +# This yml works with jBake 2.4.0 +# Feel free to change JBAKE_VERSION version +# +# HowTo at: https://jorge.aguilera.gitlab.io/howtojbake/ + +image: java:8 + +variables: + JBAKE_VERSION: 2.4.0 + + +# We use SDKMan as tool for managing versions +before_script: + - apt-get update -qq && apt-get install -y -qq unzip + - curl -sSL https://get.sdkman.io | bash + - echo sdkman_auto_answer=true > /root/.sdkman/etc/config + - source /root/.sdkman/bin/sdkman-init.sh + - sdk install jbake $JBAKE_VERSION < /dev/null + - sdk use jbake $JBAKE_VERSION + +# This build job produced the output directory of your site +pages: + script: + - jbake . public + artifacts: + paths: + - public
\ No newline at end of file diff --git a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml index 2a761bbd127..166f146ee05 100644 --- a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml @@ -10,6 +10,9 @@ services: - redis:latest - postgres:latest +variables: + POSTGRES_DB: database_name + # Cache gems in between builds cache: paths: @@ -19,6 +22,8 @@ cache: # services such as redis or postgres before_script: - ruby -v # Print out ruby version for debugging + # Uncomment next line if your rails app needs a JS runtime: + # - apt-get update -q && apt-get install nodejs -yqq - gem install bundler --no-ri --no-rdoc # Bundler is not installed with the image - bundle install -j $(nproc) --path vendor # Install dependencies into ./vendor/ruby @@ -32,6 +37,9 @@ rspec: - rspec spec rails: + variables: + DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/$POSTGRES_DB" script: - bundle exec rake db:migrate + - bundle exec rake db:seed - bundle exec rake test |