├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── controllers │ └── replies_controller.rb ├── models │ └── reply.rb └── views │ └── replies │ ├── _create.html.erb │ ├── _form.html.erb │ ├── _list.html.erb │ ├── edit.html.erb │ ├── index.html.erb │ └── preview.html.erb ├── assets ├── javascripts │ └── replies.js └── stylesheets │ └── replies.css ├── config ├── locales │ ├── en.yml │ ├── fr.yml │ └── ru.yml └── routes.rb ├── db └── migrate │ ├── 001_create_replies.rb │ └── 002_add_is_public_to_replies_table.rb ├── doc └── images │ ├── access-quick-replies.png │ └── managing-quick-replies.png ├── init.rb ├── lib ├── redmine_quick_replies.rb └── redmine_quick_replies │ ├── hooks │ └── add_replies_link.rb │ └── patches │ ├── user_patch.rb │ └── wiki_formatting_patch.rb └── test ├── fixtures └── replies.yml ├── functional └── replies_controller_test.rb └── test_helper.rb /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 4 | 5 | ## v1.3.1 6 | 7 | ### Updated 8 | 9 | * Update how the plugin patch wiki_formatting for better compatibility with other plugins 10 | 11 | ## v1.3.0 12 | 13 | ### Added 14 | 15 | * Allow public replies to be created 16 | * Add french translations 17 | 18 | ## v1.2.0 19 | 20 | ### Added 21 | 22 | * Add support for Redmine 4.1 23 | 24 | ## v1.1.0 25 | 26 | ### Added 27 | 28 | * Add support for Redmine 4 29 | * Explain how to use the plugin in the README 30 | 31 | ### Changed 32 | 33 | * Order quick replies in alphabetical order 34 | * Truncate quick replies with too long name 35 | 36 | ### Fixed 37 | 38 | * Hide quick replies button if none are available 39 | 40 | ## v1.0.0 41 | 42 | 🎉 Initial release! -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at info@exolnet.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/eXolnet/redmine_quick_replies). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[Ruby Coding Standard](https://github.com/styleguide/ruby)** - Check the code style before commiting. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ bundle exec rake redmine:plugins:test NAME=redmine_quick_replies 29 | ``` 30 | 31 | **Happy coding**! 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-present eXolnet Inc. (https://www.exolnet.com/) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redmine Quick Replies 2 | 3 | [![Latest Release](https://img.shields.io/github/release/eXolnet/redmine_quick_replies.svg?style=flat-square)](https://github.com/eXolnet/redmine_quick_replies/releases) 4 | ![Redmine Compatibility](https://img.shields.io/static/v1?label=redmine&message=4.2.x-5.1.x&color=blue&style=flat-square) 5 | [![Software License](https://img.shields.io/badge/license-MIT-8469ad.svg?style=flat-square)](LICENSE) 6 | [![Build Status](https://img.shields.io/github/actions/workflow/status/eXolnet/redmine_quick_replies/tests.yml?label=tests&style=flat-square)](https://github.com/eXolnet/redmine_quick_replies/actions?query=workflow%3Atests) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/3789abac23b73a9bf71b/maintainability)](https://codeclimate.com/github/eXolnet/redmine_quick_replies/maintainability) 8 | 9 | Save time by creating quick replies that could be reused in any WYSIWYG editors. 10 | 11 | ## Compatibility 12 | 13 | This plugin version is compatible only with Redmine 4.2 and later. 14 | 15 | ## Installation 16 | 17 | 1. Download the .ZIP archive, extract files and copy the plugin directory to `#{REDMINE_ROOT}/plugins/redmine_quick_replies`. 18 | 19 | 2. Make a backup of your database, then run the following command to update it: 20 | 21 | ```bash 22 | bundle exec rake redmine:plugins:migrate NAME=redmine_quick_replies RAILS_ENV=production 23 | ``` 24 | 25 | 3. Restart Redmine. 26 | 27 | ### Uninstall 28 | 29 | 1. Make a backup of your database, then rollback the migrations: 30 | 31 | ```bash 32 | bundle exec rake redmine:plugins:migrate NAME=redmine_quick_replies VERSION=0 RAILS_ENV=production 33 | ``` 34 | 35 | 2. Remove the plugin's folder from `#{REDMINE_ROOT}/plugins`. 36 | 37 | 3. Restart Redmine. 38 | 39 | ## Usage 40 | 41 | ### Using quick replies 42 | 43 | Quick replies could be used on any WYSIWYG editor available on Redmine: 44 | 45 | 1. Click on the "Quick Replies" button; 46 | 2. Select the quick reply that you want to append by its name. 47 | 48 | ![Accessing the quick replies menu](https://github.com/eXolnet/redmine_quick_replies/blob/master/doc/images/access-quick-replies.png?raw=true) 49 | 50 | ### Managing quick replies 51 | 52 | Access your quick replies through your account: 53 | 54 | 1. Click on the "My account" on Redmine's top menu; 55 | 2. Click on the "Quick replies"; 56 | 3. Create, edit or delete your quick replies though this page. 57 | 58 | ![Page to manage quick replies](https://github.com/eXolnet/redmine_quick_replies/blob/master/doc/images/managing-quick-replies.png?raw=true) 59 | 60 | ## Testing 61 | 62 | Run tests using the following command: 63 | 64 | ```bash 65 | bundle exec rake redmine:plugins:test NAME=redmine_quick_replies RAILS_ENV=test 66 | ``` 67 | 68 | ## Contributing 69 | 70 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE OF CONDUCT](CODE_OF_CONDUCT.md) for details. 71 | 72 | ## Security 73 | 74 | If you discover any security related issues, please email security@exolnet.com instead of using the issue tracker. 75 | 76 | ## Credits 77 | 78 | - [Alexandre D'Eschambeault](https://github.com/xel1045) 79 | - [All Contributors](../../contributors) 80 | 81 | ## License 82 | 83 | Copyright © [eXolnet](https://www.exolnet.com). All rights reserved. 84 | 85 | This code is licensed under the [MIT license](http://choosealicense.com/licenses/mit/). 86 | Please see the [license file](LICENSE) for more information. 87 | -------------------------------------------------------------------------------- /app/controllers/replies_controller.rb: -------------------------------------------------------------------------------- 1 | class RepliesController < ApplicationController 2 | before_action :require_login, :find_user 3 | before_action :build_new_reply_from_params, :only => [:index, :create] 4 | before_action :find_reply, :only => [:edit, :update, :destroy] 5 | before_action :find_replies, :only => [:index, :create] 6 | before_action :update_reply_from_params, :only => [:update] 7 | 8 | def index 9 | # 10 | end 11 | 12 | def create 13 | unless @reply.save 14 | render :action => 'index' 15 | return 16 | end 17 | 18 | flash[:notice] = l(:notice_reply_successful_create) 19 | redirect_to replies_path 20 | end 21 | 22 | def preview 23 | @body = params[:reply] ? params[:reply][:body] : nil 24 | 25 | render :layout => false 26 | end 27 | 28 | def edit 29 | # 30 | end 31 | 32 | def update 33 | unless @reply.save 34 | render :action => 'edit' 35 | return 36 | end 37 | 38 | flash[:notice] = l(:notice_reply_successful_update) 39 | redirect_to replies_path 40 | end 41 | 42 | def destroy 43 | @reply.destroy 44 | 45 | flash[:notice] = l(:notice_reply_successful_delete) 46 | 47 | redirect_to replies_path 48 | end 49 | 50 | private 51 | 52 | def find_user 53 | render_403 unless User.current.allowed_to_create_replies? 54 | 55 | @user = User.current 56 | end 57 | 58 | def build_new_reply_from_params 59 | @reply = Reply.new 60 | @reply.user ||= User.current 61 | 62 | update_reply_from_params 63 | end 64 | 65 | def update_reply_from_params 66 | attrs = (params[:reply] || {}).deep_dup 67 | 68 | @reply.safe_attributes = attrs 69 | end 70 | 71 | def find_reply 72 | reply_id = params[:reply_id] || params[:id] 73 | 74 | @reply = Reply.find(reply_id) 75 | raise Unauthorized unless @reply.editable? 76 | rescue ActiveRecord::RecordNotFound 77 | render_404 78 | end 79 | 80 | def find_replies 81 | @replies = Reply.editable.sorted 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /app/models/reply.rb: -------------------------------------------------------------------------------- 1 | class Reply < ActiveRecord::Base 2 | include Redmine::SafeAttributes 3 | 4 | NAME_LENGTH_LIMIT = 60 5 | 6 | ## Attributes 7 | attr_protected :id, :user_id if ActiveRecord::VERSION::MAJOR <= 4 8 | 9 | safe_attributes :name, :body 10 | 11 | safe_attributes :is_public, :if => lambda {|reply, user| user.allowed_to_manage_public_replies?} 12 | 13 | ## Relations 14 | belongs_to :user 15 | 16 | ## Validations 17 | validates :user_id, presence: true 18 | 19 | validates :name, presence: true, 20 | uniqueness: { case_sensitive: true, scope: :user_id }, 21 | length: { maximum: NAME_LENGTH_LIMIT } 22 | 23 | validates :body, presence: true 24 | 25 | ## Scopes 26 | scope :all_public, lambda { where(:is_public => true) } 27 | 28 | scope :author, lambda {|*args| 29 | user = args.first || User.current 30 | 31 | where(:user_id => user.id) 32 | } 33 | 34 | scope :editable, lambda {|*args| 35 | user = args.first || User.current 36 | 37 | return visible if user.allowed_to_manage_public_replies? 38 | 39 | author(user) 40 | } 41 | 42 | scope :sorted, lambda { order(:name, :id) } 43 | 44 | scope :visible, lambda {|*args| 45 | user = args.first || User.current 46 | 47 | where("(#{table_name}.is_public=? OR #{table_name}.user_id=?)", true, user.id) 48 | } 49 | 50 | def author?(user=User.current) 51 | self.user == user 52 | end 53 | 54 | def editable?(user=User.current) 55 | author?(user) || (is_public? && user.allowed_to_manage_public_replies?) 56 | end 57 | 58 | def is_private? 59 | !is_public? 60 | end 61 | 62 | def is_public? 63 | is_public 64 | end 65 | 66 | def visible?(user=User.current) 67 | is_public? || author?(user) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /app/views/replies/_create.html.erb: -------------------------------------------------------------------------------- 1 | <%= labelled_form_for reply, :url => replies_path, :html => {:id => 'reply-form'} do |f| %> 2 | <%= render :partial => 'form', :locals => {:f => f} %> 3 | 4 | <%= submit_tag l(:button_create), :name => 'create_button' %> 5 | <% if Redmine::VERSION.to_s < '4.0' %> 6 | <%= preview_link preview_reply_path, 'reply-form' %> | 7 | <% end %> 8 | <%= link_to l(:button_cancel), my_account_path %> 9 | <% end %> 10 | 11 |
12 | -------------------------------------------------------------------------------- /app/views/replies/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= error_messages_for 'reply' %> 2 | 3 |
4 |

<%= f.text_field :name, :required => true, :size => 60 %>

5 | 6 | <% if User.current.allowed_to_manage_public_replies? %> 7 |

<%= f.check_box :is_public %>

8 | <% end %> 9 | 10 |

<%= f.text_area :body, :required => true, :cols => 60, :rows => 7, :label => :label_reply, :class => 'reply-edit', :placeholder => l(:label_leave_comment), :id => 'reply_body', :style => 'width: 97%;' %>

11 |
12 | 13 | <% if Redmine::VERSION.to_s >= '4.0' %> 14 | <%= wikitoolbar_for 'reply_body', preview_reply_path %> 15 | <% else %> 16 | <%= wikitoolbar_for 'reply_body' %> 17 | <% end %> 18 | -------------------------------------------------------------------------------- /app/views/replies/_list.html.erb: -------------------------------------------------------------------------------- 1 | <% unless replies.empty? %> 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <% replies.each do |reply| %> 13 | 14 | 18 | 19 | 20 | 21 | 25 | 26 | <% end %> 27 | 28 |
<%=l(:label_reply)%><%=l(:field_is_public)%>
15 | <%= reply.name %>
16 |
<%= reply.body %>
17 |
<%= checked_image reply.is_public? %> 22 | <%= link_to l(:button_edit), edit_reply_path(:id => reply), :class => 'icon icon-edit' %> 23 | <%= delete_link reply_path(reply) %> 24 |
29 | <% else %> 30 |
<%= l(:label_no_quick_reply) %>
31 | <% end %> -------------------------------------------------------------------------------- /app/views/replies/edit.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:label_edit_quick_reply) %>

2 | 3 | <%= labelled_form_for @reply, :html => {:id => 'reply-form'} do |f| %> 4 | <%= render :partial => 'form', :locals => {:f => f} %> 5 | 6 | <%= submit_tag l(:button_update), :name => 'update_button' %> 7 | <% if Redmine::VERSION.to_s < '4.0' %> 8 | <%= preview_link preview_reply_path, 'reply-form' %> | 9 | <% end %> 10 | <%= link_to l(:button_cancel), replies_path %> 11 | <% end %> 12 | 13 | <% if Redmine::VERSION.to_s < '4.0' %> 14 |
15 | <% end %> 16 | -------------------------------------------------------------------------------- /app/views/replies/index.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:label_quick_replies) %>

2 | 3 | <%= render :partial => 'list', :locals => {:replies => @replies} %> 4 | 5 |

<%= l(:label_new_quick_reply) %>

6 | <%= render :partial => 'create', :locals => {:reply => @reply} %> 7 | -------------------------------------------------------------------------------- /app/views/replies/preview.html.erb: -------------------------------------------------------------------------------- 1 | <% if @body %> 2 |
<%= l(:label_reply) %> 3 | <%= textilizable @body, :object => @reply %> 4 |
5 | <% end %> 6 | -------------------------------------------------------------------------------- /assets/javascripts/replies.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | if (redmine_quick_replies.length === 0) { 3 | return; 4 | } 5 | 6 | jsToolBar.prototype.elements.replies = { 7 | type: 'button', 8 | title: 'Replies', 9 | fn: { 10 | wiki: function() { 11 | var editor = this, 12 | dropdown = $(''); 13 | 14 | for (var i = 0; i < redmine_quick_replies.length; i++) { 15 | var reply = redmine_quick_replies[i], 16 | li = $('
  • '); 17 | 18 | li.data('reply', reply); 19 | li.html($('
    ').text(reply.name)); 20 | li.attr('title', reply.name); 21 | li.appendTo(dropdown) 22 | 23 | li.mousedown(function() { 24 | var body = $(this).data('reply').body; 25 | 26 | editor.encloseSelection(body); 27 | }); 28 | } 29 | 30 | dropdown.menu().width(200).position({ 31 | my: 'left top', 32 | at: 'left bottom', 33 | of: this.toolNodes.replies 34 | }); 35 | 36 | $(document).on('mousedown', function() { 37 | dropdown.remove(); 38 | }); 39 | 40 | $('body').append(dropdown); 41 | } 42 | } 43 | }; 44 | })(); 45 | -------------------------------------------------------------------------------- /assets/stylesheets/replies.css: -------------------------------------------------------------------------------- 1 | .jstb_replies { 2 | background-image: url(../../../images/comment.png); 3 | } 4 | 5 | .quick-replies__menu { 6 | max-width: 225px; 7 | position: absolute; 8 | } 9 | 10 | .quick-replies__menu li { 11 | 12 | overflow: hidden; 13 | text-overflow: ellipsis; 14 | white-space: nowrap; 15 | } 16 | 17 | .reply__excerpt { 18 | color: #aaa; 19 | font-size: 90%; 20 | } 21 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | activerecord: 4 | attributes: 5 | reply: 6 | body: "Reply" 7 | label_quick_replies: "Quick replies" 8 | label_no_quick_reply: "You don't have any quick reply saved." 9 | label_new_quick_reply: "New quick reply" 10 | label_edit_quick_reply: "Edit quick reply" 11 | label_reply: Reply 12 | notice_reply_successful_create: "Quick reply successfully created." 13 | notice_reply_successful_update: "Quick reply successfully updated." 14 | notice_reply_successful_delete: "Quick reply successfully deleted." 15 | text_reply_destroy_confirmation: "Are you sure you want to delete this quick reply?" -------------------------------------------------------------------------------- /config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | # French strings go here for Rails i18n 2 | fr: 3 | activerecord: 4 | attributes: 5 | reply: 6 | body: "Réponse" 7 | label_quick_replies: "Réponses rapides" 8 | label_no_quick_reply: "Vous n'avez pas de réponse rapide enregistrée." 9 | label_new_quick_reply: "Nouvelle réponse rapide" 10 | label_edit_quick_reply: "Modifier la réponse rapide" 11 | label_reply: Réponse 12 | notice_reply_successful_create: "La réponse rapide a été créée avec succès." 13 | notice_reply_successful_update: "La réponse rapide a été modifiée avec succès." 14 | notice_reply_successful_delete: "La réponse rapide a été supprimée avec succès." 15 | text_reply_destroy_confirmation: "Voulez-vous vraiment supprimer cette réponse rapide?" -------------------------------------------------------------------------------- /config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | # Russian strings go here for Rails i18n 2 | ru: 3 | activerecord: 4 | attributes: 5 | reply: 6 | body: "Ответ" 7 | label_quick_replies: "Быстрые ответы" 8 | label_no_quick_reply: "У вас нет сохраненных быстрых ответов." 9 | label_new_quick_reply: "Новый быстрый ответ" 10 | label_edit_quick_reply: "Редактировать быстрый ответ" 11 | label_reply: Ответ 12 | notice_reply_successful_create: "Быстрый ответ успешно создан." 13 | notice_reply_successful_update: "Быстрый ответ успешно обновлён." 14 | notice_reply_successful_delete: "Быстрый ответ успешно удалён." 15 | text_reply_destroy_confirmation: "Вы уверены, что хотите удалить этот быстрый ответ?" 16 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # Plugin's routes 2 | # See: http://guides.rubyonrails.org/routing.html 3 | 4 | scope 'my' do 5 | match 'replies/preview', :to => 'replies#preview', :as => 'preview_reply', :via => [:get, :post, :put, :patch] 6 | resources :replies, controller: 'replies' 7 | end -------------------------------------------------------------------------------- /db/migrate/001_create_replies.rb: -------------------------------------------------------------------------------- 1 | migration_class = ActiveRecord::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration 2 | 3 | class CreateReplies < migration_class 4 | def change 5 | create_table :replies do |t| 6 | t.column :user_id, :integer, :null => false 7 | t.string :name, :null => false 8 | t.column :body, :text 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/002_add_is_public_to_replies_table.rb: -------------------------------------------------------------------------------- 1 | migration_class = ActiveRecord::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[4.2] : ActiveRecord::Migration 2 | 3 | class AddIsPublicToRepliesTable < migration_class 4 | def self.up 5 | add_column :replies, :is_public, :boolean, :default => false, :null => false 6 | end 7 | 8 | def self.down 9 | remove_column :replies, :is_public 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /doc/images/access-quick-replies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eXolnet/redmine_quick_replies/00a94e6f43990234be438aa1c60b4b144a8b5b04/doc/images/access-quick-replies.png -------------------------------------------------------------------------------- /doc/images/managing-quick-replies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eXolnet/redmine_quick_replies/00a94e6f43990234be438aa1c60b4b144a8b5b04/doc/images/managing-quick-replies.png -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | 3 | REDMINE_QUICK_REPLIES_VERSION = '1.4.1' 4 | 5 | Redmine::Plugin.register :redmine_quick_replies do 6 | name 'Quick Replies' 7 | author 'eXolnet' 8 | description 'Save time by creating quick replies that could be reused in any WYSIWYG editors.' 9 | version REDMINE_QUICK_REPLIES_VERSION 10 | url 'https://github.com/eXolnet/redmine_quick_replies' 11 | author_url 'https://www.exolnet.com' 12 | 13 | requires_redmine :version_or_higher => '4.2' 14 | 15 | permission :create_replies, replies: [:index, :create, :edit, :update, :destroy], require: :loggedin 16 | permission :manage_public_replies, {} 17 | end 18 | 19 | require File.dirname(__FILE__) + '/lib/redmine_quick_replies' 20 | -------------------------------------------------------------------------------- /lib/redmine_quick_replies.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/redmine_quick_replies/hooks/add_replies_link' 2 | 3 | require File.dirname(__FILE__) + '/redmine_quick_replies/patches/user_patch' 4 | require File.dirname(__FILE__) + '/redmine_quick_replies/patches/wiki_formatting_patch' 5 | 6 | module RedmineQuickReplies 7 | class << self 8 | def setup 9 | Redmine::WikiFormatting::format_names.each do |format| 10 | unless Redmine::WikiFormatting::helper_for(format).included_modules.include? RedmineQuickReplies::Patches::WikiFormattingPatch 11 | Redmine::WikiFormatting::helper_for(format).send(:include, RedmineQuickReplies::Patches::WikiFormattingPatch) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | 18 | if Rails.version > '6.0' && Rails.autoloaders.zeitwerk_enabled? 19 | Rails.application.config.after_initialize do 20 | RedmineQuickReplies.setup 21 | end 22 | else 23 | Rails.configuration.to_prepare do 24 | RedmineQuickReplies.setup 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/redmine_quick_replies/hooks/add_replies_link.rb: -------------------------------------------------------------------------------- 1 | module RedmineQuickReplies 2 | module Hooks 3 | class AddRepliesLink < Redmine::Hook::ViewListener 4 | def view_my_account_contextual(context) 5 | user = context[:user] 6 | link_to(l(:label_quick_replies), replies_path, class: 'icon icon-comment') if user.allowed_to_create_replies? 7 | end 8 | 9 | def self.default_url_options 10 | {:script_name => Redmine::Utils.relative_url_root} 11 | end 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /lib/redmine_quick_replies/patches/user_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'user' 2 | 3 | module RedmineQuickReplies 4 | module Patches 5 | module UserPatch 6 | def self.included(base) # :nodoc: 7 | base.send(:include, InstanceMethods) 8 | end 9 | 10 | module InstanceMethods 11 | def allowed_to_create_replies? 12 | allowed_to?(:create_replies, nil, global: true) 13 | end 14 | 15 | def allowed_to_manage_public_replies? 16 | allowed_to?(:manage_public_replies, nil, global: true) 17 | end 18 | end 19 | end 20 | end 21 | end 22 | 23 | unless User.included_modules.include?(RedmineQuickReplies::Patches::UserPatch) 24 | User.send(:include, RedmineQuickReplies::Patches::UserPatch) 25 | end 26 | -------------------------------------------------------------------------------- /lib/redmine_quick_replies/patches/wiki_formatting_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineQuickReplies 2 | module Patches 3 | module WikiFormattingPatch 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | prepend InstanceOverwriteMethods 8 | end 9 | 10 | module InstanceOverwriteMethods 11 | def heads_for_wiki_formatter 12 | super 13 | 14 | return if @heads_for_wiki_redmine_quick_replies_included 15 | 16 | content_for :header_tags do 17 | replies = Reply.visible.sorted.to_json 18 | 19 | o = javascript_tag("redmine_quick_replies = " + replies + ";") 20 | o << javascript_include_tag('replies', :plugin => 'redmine_quick_replies') 21 | o << stylesheet_link_tag('replies', :plugin => 'redmine_quick_replies') 22 | o.html_safe 23 | end 24 | 25 | @heads_for_wiki_redmine_quick_replies_included = true 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/fixtures/replies.yml: -------------------------------------------------------------------------------- 1 | --- 2 | replies_001: 3 | id: 1 4 | user_id: 1 5 | name: Reply 1 6 | body: Dapibus ligula phasellus 7 | is_public: false 8 | replies_002: 9 | id: 2 10 | user_id: 2 11 | name: Reply 2 12 | body: Metus tellus conubia 13 | is_public: false 14 | replies_003: 15 | id: 3 16 | user_id: 1 17 | name: Reply 3 18 | body: Mollis facilisis convallis 19 | is_public: true 20 | -------------------------------------------------------------------------------- /test/functional/replies_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class RepliesControllerTest < ActionController::TestCase 4 | fixtures :users, 5 | :replies 6 | 7 | def setup 8 | @request.session[:user_id] = 1 9 | end 10 | 11 | def test_index 12 | compatible_request :get, :index 13 | 14 | assert_response :success 15 | 16 | assert_select 'table.replies' do 17 | assert_select 'tbody tr' do 18 | assert_select 'a[href="/my/replies/1/edit"]' 19 | assert_select 'a[href="/my/replies/1"][data-method="delete"]' 20 | end 21 | 22 | assert_select 'a[href="/my/replies/2"]', 0 23 | end 24 | end 25 | 26 | def test_index_loads_quick_replies_in_wiki_editor 27 | compatible_request :get, :index 28 | 29 | assert_response :success 30 | 31 | assert_select 'script[src*="/plugin_assets/redmine_quick_replies/javascripts/replies.js"]' 32 | assert_select 'link[href*="/plugin_assets/redmine_quick_replies/stylesheets/replies.css"]' 33 | end 34 | 35 | def test_create_reply_with_valid_fields 36 | reply = new_record(Reply) do 37 | compatible_request :post, :create, :reply => { :name => 'Quick Reply Name', :body => 'Body' } 38 | 39 | assert_response 302 40 | assert_redirected_to :controller => 'replies', :action => 'index' 41 | end 42 | 43 | assert User.current, reply.user 44 | end 45 | 46 | def test_create_reply_public 47 | reply = new_record(Reply) do 48 | compatible_request :post, :create, :reply => { :name => 'Quick Reply Name', :is_public => '1', :body => 'Body' } 49 | 50 | assert_response 302 51 | assert_redirected_to :controller => 'replies', :action => 'index' 52 | end 53 | 54 | assert User.current, reply.user 55 | assert true, reply.is_public 56 | end 57 | 58 | def test_create_reply_should_validate_required_fields_on_empty_post 59 | compatible_request :post, :create 60 | 61 | assert_response :success 62 | 63 | assert_select_error(/Name cannot be blank/i) 64 | assert_select_error(/Reply cannot be blank/i) 65 | end 66 | 67 | def test_create_reply_should_validate_required_fields 68 | compatible_request :post, :create, :reply => { :name => '', :body => '' } 69 | 70 | assert_response :success 71 | 72 | assert_select_error(/Name cannot be blank/i) 73 | assert_select_error(/Reply cannot be blank/i) 74 | end 75 | 76 | def test_create_reply_without_manage_public_permission 77 | @request.session[:user_id] = 2 78 | 79 | compatible_request :post, :create, :reply => { :name => 'Quick Reply Name', :is_public => '1', :body => 'Body' } 80 | 81 | assert_response 403 82 | end 83 | 84 | def test_edit_should_load_content 85 | compatible_request :get, :edit, :id => 1 86 | 87 | assert_response :success 88 | 89 | assert_select 'input[type="text"][value="Reply 1"]' 90 | end 91 | 92 | def test_edit_for_non_existing_reply 93 | compatible_request :get, :edit, :id => 999 94 | 95 | assert_response 404 96 | end 97 | 98 | def test_edit_for_another_user_reply 99 | compatible_request :get, :edit, :id => 2 100 | 101 | assert_response 403 102 | end 103 | 104 | def test_update_reply_with_valid_fields 105 | compatible_request :put, :update, :id => 1, :reply => { :name => 'Quick Reply Name', :body => 'Body' } 106 | 107 | assert_response 302 108 | assert_redirected_to :controller => 'replies', :action => 'index' 109 | end 110 | 111 | def test_update_for_non_existing_reply 112 | compatible_request :put, :update, :id => 999 113 | 114 | assert_response 404 115 | end 116 | 117 | def test_update_for_another_user_reply 118 | compatible_request :put, :update, :id => 2 119 | 120 | assert_response 403 121 | end 122 | 123 | def test_update_reply_should_keep_reply_unchanged_on_empty_post 124 | compatible_request :put, :update, :id => 1 125 | 126 | assert_response 302 127 | assert_redirected_to :controller => 'replies', :action => 'index' 128 | end 129 | 130 | def test_update_reply_should_validate_required_fields 131 | compatible_request :put, :update, :id => 1, :reply => { :name => '', :body => '' } 132 | 133 | assert_response :success 134 | 135 | assert_select_error(/Name cannot be blank/i) 136 | assert_select_error(/Reply cannot be blank/i) 137 | end 138 | 139 | def test_update_without_manage_public_permission 140 | @request.session[:user_id] = 2 141 | 142 | compatible_request :put, :update, :id => 1, :reply => { :name => 'Quick Reply Name', :is_public => '1', :body => 'Body' } 143 | 144 | assert_response 403 145 | end 146 | 147 | def test_destroy_reply 148 | compatible_request :delete, :destroy, :id => 1 149 | 150 | assert_response 302 151 | assert_redirected_to :controller => 'replies', :action => 'index' 152 | assert ! Reply.find_by_id(1) 153 | end 154 | 155 | def test_destroy_for_non_existing_reply 156 | compatible_request :delete, :destroy, :id => 999 157 | 158 | assert_response 404 159 | end 160 | 161 | def test_destroy_for_another_user_reply 162 | compatible_request :delete, :destroy, :id => 2 163 | 164 | assert_response 403 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $VERBOSE = nil # for hide ruby warnings 2 | 3 | # Load the Redmine helper 4 | require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper') 5 | 6 | # Enable project fixtures 7 | ActiveRecord::FixtureSet.create_fixtures(File.dirname(__FILE__) + '/fixtures/', [:replies]) 8 | 9 | module RedmineQuickReplies 10 | module TestHelper 11 | def compatible_request(type, action, parameters = {}) 12 | return send(type, action, :params => parameters) if Rails.version >= '5.1' 13 | send(type, action, parameters) 14 | end 15 | 16 | def compatible_xhr_request(type, action, parameters = {}) 17 | return send(type, action, :params => parameters, :xhr => true) if Rails.version >= '5.1' 18 | xhr type, action, parameters 19 | end 20 | 21 | def compatible_api_request(type, action, parameters = {}, headers = {}) 22 | return send(type, action, :params => parameters, :headers => headers) if Redmine::VERSION.to_s >= '3.4' 23 | send(type, action, parameters, headers) 24 | end 25 | end 26 | end 27 | 28 | include RedmineQuickReplies::TestHelper 29 | --------------------------------------------------------------------------------