mirror of
https://github.com/anteo/redmine_custom_workflows.git
synced 2026-01-26 08:14:20 +00:00
Initial commit (#8593)
This commit is contained in:
commit
c105acf3b8
3
README.rdoc
Normal file
3
README.rdoc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
= custom_workflow
|
||||||
|
|
||||||
|
Description goes here
|
||||||
71
app/controllers/custom_workflows_controller.rb
Normal file
71
app/controllers/custom_workflows_controller.rb
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
class CustomWorkflowsController < ApplicationController
|
||||||
|
unloadable
|
||||||
|
|
||||||
|
layout 'admin'
|
||||||
|
before_filter :require_admin
|
||||||
|
before_filter :find_workflow, :only => [:show, :edit, :update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@workflows = CustomWorkflow.all
|
||||||
|
respond_to do |format|
|
||||||
|
format.html
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to edit_custom_workflow_path }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@workflow = CustomWorkflow.new
|
||||||
|
respond_to do |format|
|
||||||
|
format.html
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@workflow = CustomWorkflow.new(params[:custom_workflow])
|
||||||
|
respond_to do |format|
|
||||||
|
if @workflow.save
|
||||||
|
flash[:notice] = l(:notice_successful_create)
|
||||||
|
format.html { redirect_to(custom_workflows_path) }
|
||||||
|
else
|
||||||
|
format.html { render :action => "new" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
respond_to do |format|
|
||||||
|
if @workflow.update_attributes(params[:custom_workflow])
|
||||||
|
flash[:notice] = l(:notice_successful_update)
|
||||||
|
format.html { redirect_to(custom_workflows_path) }
|
||||||
|
else
|
||||||
|
format.html { render :action => :edit }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@workflow.destroy
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
flash[:notice] = l(:notice_successful_delete)
|
||||||
|
format.html { redirect_to(custom_workflows_path) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_workflow
|
||||||
|
@workflow = CustomWorkflow.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render_404
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
36
app/models/custom_workflow.rb
Normal file
36
app/models/custom_workflow.rb
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
class WorkflowError < StandardError
|
||||||
|
attr_accessor :error
|
||||||
|
def initialize(message)
|
||||||
|
@error = message.dup
|
||||||
|
super message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CustomWorkflow < ActiveRecord::Base
|
||||||
|
has_and_belongs_to_many :projects
|
||||||
|
acts_as_list
|
||||||
|
|
||||||
|
default_scope :order => 'position ASC'
|
||||||
|
validates_presence_of :script
|
||||||
|
validates_presence_of :name
|
||||||
|
validates_uniqueness_of :name, :case_sensitive => false
|
||||||
|
validate :validate_syntax
|
||||||
|
|
||||||
|
def eval_script(context)
|
||||||
|
context.each { |k, v| instance_variable_set ("@#{k}").to_sym, v }
|
||||||
|
eval(script)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_syntax
|
||||||
|
begin
|
||||||
|
eval_script(:issue => Issue.new)
|
||||||
|
rescue WorkflowError => e
|
||||||
|
rescue Exception => e
|
||||||
|
errors.add :script, :invalid_script, :error => e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
name
|
||||||
|
end
|
||||||
|
end
|
||||||
24
app/views/custom_workflows/_form.html.erb
Normal file
24
app/views/custom_workflows/_form.html.erb
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
<div class="box tabular">
|
||||||
|
<p><%= f.text_field :name, :required => true, :size => 50 %></p>
|
||||||
|
<p><%= f.text_area :description, :cols => 80, :rows => 5 %></p>
|
||||||
|
<p><%= f.text_area :script, :cols => 80, :rows => 20, :wrap => 'off', :required => true %>
|
||||||
|
<em class="info"><%= l(:text_custom_workflow_script_note) %></em>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
jQuery('#custom_workflow_script').taboverride(2, true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%= wikitoolbar_for :custom_workflow_description %>
|
||||||
|
|
||||||
|
<% content_for :header_tags do %>
|
||||||
|
<% if (Redmine::VERSION::ARRAY <=> [2,1,0]) < 0 %>
|
||||||
|
<%= javascript_include_tag "jquery-1.8.1.min.js", :plugin => 'redmine_custom_workflows' %>
|
||||||
|
<script type="text/javascript">
|
||||||
|
jQuery.noConflict();
|
||||||
|
</script>
|
||||||
|
<% end %>
|
||||||
|
<%= javascript_include_tag "tab_override", :plugin => 'redmine_custom_workflows' %>
|
||||||
|
<% end %>
|
||||||
12
app/views/custom_workflows/edit.html.erb
Normal file
12
app/views/custom_workflows/edit.html.erb
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<h2><%= link_to l(:label_custom_workflow_plural), custom_workflows_path %> » <%= @workflow %></h2>
|
||||||
|
|
||||||
|
<%= error_messages_for @workflow %>
|
||||||
|
|
||||||
|
<% form = labelled_form_for @workflow do |f| %>
|
||||||
|
<%= render :partial => 'form', :locals => {:f => f} %>
|
||||||
|
<%= submit_tag l(:button_save) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form if Redmine::VERSION::MAJOR >= 2 %>
|
||||||
|
|
||||||
|
<% html_title(l(:label_custom_workflow_plural), @workflow, l(:label_administration)) -%>
|
||||||
35
app/views/custom_workflows/index.html.erb
Normal file
35
app/views/custom_workflows/index.html.erb
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<% html_title(l(:label_custom_workflow_plural)) -%>
|
||||||
|
<div class="contextual">
|
||||||
|
<%= link_to l(:label_custom_workflow_new), new_custom_workflow_path, :class => 'icon icon-add' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2><%=l(:label_custom_workflow_plural)%></h2>
|
||||||
|
|
||||||
|
<div class="autoscroll">
|
||||||
|
<% if @workflows.any? %>
|
||||||
|
<table class="custom-workflows list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%=l(:field_name)%></th>
|
||||||
|
<th><%=l(:field_description)%></th>
|
||||||
|
<th style="width:15%;"><%= l(:button_sort) %></th>
|
||||||
|
<th style="width:10%;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @workflows.each do |workflow| %>
|
||||||
|
<tr class="<%= cycle("odd", "even") %>">
|
||||||
|
<td class="name"><%= link_to(workflow.name, edit_custom_workflow_path(workflow)) %></td>
|
||||||
|
<td><%= textilizable(workflow.description) %></td>
|
||||||
|
<td align="center"><%= reorder_links("custom_workflow", {:action => 'update', :id => workflow}, :put) %></td>
|
||||||
|
<td class="buttons">
|
||||||
|
<%= link_to(l(:button_delete), workflow, :class => 'icon icon-del', :data => {:confirm => l(:text_are_you_sure)}, :confirm => l(:text_are_you_sure), :method => :delete) %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% else %>
|
||||||
|
<p class="nodata"><%= l(:label_no_data) %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
12
app/views/custom_workflows/new.html.erb
Normal file
12
app/views/custom_workflows/new.html.erb
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<h2><%= link_to l(:label_custom_workflow_plural), custom_workflows_path %> » <%= l(:label_custom_workflow_new) %></h2>
|
||||||
|
|
||||||
|
<%= error_messages_for @workflow %>
|
||||||
|
|
||||||
|
<% form = labelled_form_for @workflow do |f| %>
|
||||||
|
<%= render :partial => 'form', :locals => {:f => f} %>
|
||||||
|
<%= submit_tag l(:button_create) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form if Redmine::VERSION::MAJOR >= 2 %>
|
||||||
|
|
||||||
|
<% html_title(l(:label_custom_workflow_plural), l(:label_custom_workflow_new), l(:label_administration)) -%>
|
||||||
20
app/views/projects/settings/_custom_workflow.html.erb
Normal file
20
app/views/projects/settings/_custom_workflow.html.erb
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<% form = form_for @project do |f| %>
|
||||||
|
<%= hidden_field_tag :tab, 'custom_workflow' %>
|
||||||
|
<%= hidden_field_tag 'project[custom_workflow_ids][]', '' %>
|
||||||
|
<fieldset>
|
||||||
|
<legend><%= l(:text_select_project_custom_workflows) %></legend>
|
||||||
|
<% if CustomWorkflow.exists? %>
|
||||||
|
<dl>
|
||||||
|
<% CustomWorkflow.all.each do |w| %>
|
||||||
|
<dt><label><%= check_box_tag 'project[custom_workflow_ids][]', w.id, @project.custom_workflow_ids.include?(w.id) %> <%= w.name %></label></dt>
|
||||||
|
<dd><em><%= textilizable(w.description) %></em></dd>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
<% else %>
|
||||||
|
<p class="nodata"><%= l(:label_no_data) %></p>
|
||||||
|
<% end %>
|
||||||
|
</fieldset>
|
||||||
|
<%= submit_tag l(:button_save) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form if Redmine::VERSION::MAJOR >= 2 %>
|
||||||
2
assets/javascripts/jquery-1.8.1.min.js
vendored
Normal file
2
assets/javascripts/jquery-1.8.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
324
assets/javascripts/tab_override.js
Normal file
324
assets/javascripts/tab_override.js
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
(function($) {
|
||||||
|
$.fn.taboverride = function(tabSize, autoIndent) {
|
||||||
|
this.each(function() {
|
||||||
|
$(this).data('taboverride', new TabOverride(this, tabSize, autoIndent));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function TabOverride(element, tabSize, autoIndent) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = '\n';
|
||||||
|
|
||||||
|
this.newline = ta.value;
|
||||||
|
this.newlineLen = this.newline.length;
|
||||||
|
this.autoIndent = autoIndent;
|
||||||
|
this.inWhitespace = false;
|
||||||
|
this.element = element;
|
||||||
|
this.setTabSize(tabSize);
|
||||||
|
|
||||||
|
$(element).on('keypress', $.proxy(this.overrideKeyPress, this));
|
||||||
|
$(element).on('keydown', $.proxy(this.overrideKeyDown, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
TabOverride.prototype = {
|
||||||
|
/**
|
||||||
|
* Returns the current tab size. 0 represents the tab character.
|
||||||
|
*
|
||||||
|
* @return {Number} the size (length) of the tab string or 0 for the tab character
|
||||||
|
*
|
||||||
|
* @name getTabSize
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
getTabSize:function () {
|
||||||
|
return this.aTab === '\t' ? 0 : this.aTab.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the tab size for all elements that have Tab Override enabled.
|
||||||
|
* 0 represents the tab character.
|
||||||
|
*
|
||||||
|
* @param {Number} size the tab size (default = 0)
|
||||||
|
*
|
||||||
|
* @name setTabSize
|
||||||
|
* @function
|
||||||
|
*/
|
||||||
|
setTabSize:function (size) {
|
||||||
|
var i;
|
||||||
|
if (!size) { // size is 0 or not specified (or falsy)
|
||||||
|
this.aTab = '\t';
|
||||||
|
} else if (typeof size === 'number' && size > 0) {
|
||||||
|
this.aTab = '';
|
||||||
|
for (i = 0; i < size; i += 1) {
|
||||||
|
this.aTab += ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevents the default action for the keyPress event when tab or enter are
|
||||||
|
* pressed. Opera (and Firefox) also fire a keypress event when the tab or
|
||||||
|
* enter key is pressed. Opera requires that the default action be prevented
|
||||||
|
* on this event or the textarea will lose focus.
|
||||||
|
*
|
||||||
|
* @param {Event} e the event object
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
overrideKeyPress:function (e) {
|
||||||
|
var key = e.keyCode;
|
||||||
|
if ((key === 9 || (key === 13 && this.autoIndent && !this.inWhitespace)) && !e.ctrlKey && !e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts / removes tabs and newlines on the keyDown event for the tab or enter key.
|
||||||
|
*
|
||||||
|
* @param {Event} e the event object
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
overrideKeyDown:function (e) {
|
||||||
|
var key = e.keyCode, // the key code for the key that was pressed
|
||||||
|
tab, // the string representing a tab
|
||||||
|
tabLen, // the length of a tab
|
||||||
|
text, // initial text in the textarea
|
||||||
|
range, // the IE TextRange object
|
||||||
|
tempRange, // used to calculate selection start and end positions in IE
|
||||||
|
preNewlines, // the number of newline character sequences before the selection start (for IE)
|
||||||
|
selNewlines, // the number of newline character sequences within the selection (for IE)
|
||||||
|
initScrollTop, // initial scrollTop value used to fix scrolling in Firefox
|
||||||
|
selStart, // the selection start position
|
||||||
|
selEnd, // the selection end position
|
||||||
|
sel, // the selected text
|
||||||
|
startLine, // for multi-line selections, the first character position of the first line
|
||||||
|
endLine, // for multi-line selections, the last character position of the last line
|
||||||
|
numTabs, // the number of tabs inserted / removed in the selection
|
||||||
|
startTab, // if a tab was removed from the start of the first line
|
||||||
|
preTab, // if a tab was removed before the start of the selection
|
||||||
|
whitespace, // the whitespace at the beginning of the first selected line
|
||||||
|
whitespaceLen; // the length of the whitespace at the beginning of the first selected line
|
||||||
|
|
||||||
|
// don't do any unnecessary work
|
||||||
|
if ((key !== 9 && (key !== 13 || !this.autoIndent)) || e.ctrlKey || e.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize variables used for tab and enter keys
|
||||||
|
this.inWhitespace = false; // this will be set to true if enter is pressed in the leading whitespace
|
||||||
|
text = this.element.value;
|
||||||
|
|
||||||
|
// this is really just for Firefox, but will be used by all browsers that support
|
||||||
|
// selectionStart and selectionEnd - whenever the textarea value property is reset,
|
||||||
|
// Firefox scrolls back to the top - this is used to set it back to the original value
|
||||||
|
// scrollTop is nonstandard, but supported by all modern browsers
|
||||||
|
initScrollTop = this.element.scrollTop;
|
||||||
|
|
||||||
|
// get the text selection
|
||||||
|
// prefer the nonstandard document.selection way since it allows for
|
||||||
|
// automatic scrolling to the cursor via the range.select() method
|
||||||
|
if (document.selection) { // IE
|
||||||
|
range = document.selection.createRange();
|
||||||
|
sel = range.text;
|
||||||
|
tempRange = range.duplicate();
|
||||||
|
tempRange.moveToElementText(this.element);
|
||||||
|
tempRange.setEndPoint('EndToEnd', range);
|
||||||
|
selEnd = tempRange.text.length;
|
||||||
|
selStart = selEnd - sel.length;
|
||||||
|
|
||||||
|
// whenever the value of the textarea is changed, the range needs to be reset
|
||||||
|
// IE <9 (and Opera) use both \r and \n for newlines - this adds an extra character
|
||||||
|
// that needs to be accounted for when doing position calculations with ranges
|
||||||
|
// these values are used to offset the selection start and end positions
|
||||||
|
if (this.newlineLen > 1) {
|
||||||
|
preNewlines = text.slice(0, selStart).split(this.newline).length - 1;
|
||||||
|
selNewlines = sel.split(this.newline).length - 1;
|
||||||
|
} else {
|
||||||
|
preNewlines = selNewlines = 0;
|
||||||
|
}
|
||||||
|
} else if (typeof this.element.selectionStart !== 'undefined') {
|
||||||
|
selStart = this.element.selectionStart;
|
||||||
|
selEnd = this.element.selectionEnd;
|
||||||
|
sel = text.slice(selStart, selEnd);
|
||||||
|
} else {
|
||||||
|
return; // cannot access textarea selection - do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
// tab key - insert / remove tab
|
||||||
|
if (key === 9) {
|
||||||
|
// initialize tab variables
|
||||||
|
tab = this.aTab;
|
||||||
|
tabLen = tab.length;
|
||||||
|
numTabs = 0;
|
||||||
|
startTab = 0;
|
||||||
|
preTab = 0;
|
||||||
|
|
||||||
|
// multi-line selection
|
||||||
|
if (selStart !== selEnd && sel.indexOf('\n') !== -1) {
|
||||||
|
if (text.charAt(selEnd - 1) === '\n') {
|
||||||
|
selEnd = selEnd - this.newlineLen;
|
||||||
|
sel = text.slice(selStart, selEnd);
|
||||||
|
}
|
||||||
|
// for multiple lines, only insert / remove tabs from the beginning of each line
|
||||||
|
|
||||||
|
// find the start of the first selected line
|
||||||
|
if (selStart === 0 || text.charAt(selStart - 1) === '\n') {
|
||||||
|
// the selection starts at the beginning of a line
|
||||||
|
startLine = selStart;
|
||||||
|
} else {
|
||||||
|
// the selection starts after the beginning of a line
|
||||||
|
// set startLine to the beginning of the first partially selected line
|
||||||
|
// subtract 1 from selStart in case the cursor is at the newline character,
|
||||||
|
// for instance, if the very end of the previous line was selected
|
||||||
|
// add 1 to get the next character after the newline
|
||||||
|
// if there is none before the selection, lastIndexOf returns -1
|
||||||
|
// when 1 is added to that it becomes 0 and the first character is used
|
||||||
|
startLine = text.lastIndexOf('\n', selStart - 1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the end of the last selected line
|
||||||
|
if (selEnd === text.length || text.charAt(selEnd) === '\n') {
|
||||||
|
// the selection ends at the end of a line
|
||||||
|
endLine = selEnd;
|
||||||
|
} else {
|
||||||
|
// the selection ends before the end of a line
|
||||||
|
// set endLine to the end of the last partially selected line
|
||||||
|
endLine = text.indexOf('\n', selEnd);
|
||||||
|
if (endLine === -1) {
|
||||||
|
endLine = text.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if the shift key was pressed, remove tabs instead of inserting them
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (text.slice(startLine).indexOf(tab) === 0) {
|
||||||
|
// is this tab part of the selection?
|
||||||
|
if (startLine === selStart) {
|
||||||
|
// it is, remove it
|
||||||
|
sel = sel.slice(tabLen);
|
||||||
|
} else {
|
||||||
|
// the tab comes before the selection
|
||||||
|
preTab = tabLen;
|
||||||
|
}
|
||||||
|
startTab = tabLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element.value = text.slice(0, startLine) + text.slice(startLine + preTab, selStart) +
|
||||||
|
sel.replace(new RegExp('\n' + tab, 'g'), function () {
|
||||||
|
numTabs += 1;
|
||||||
|
return '\n';
|
||||||
|
}) + text.slice(selEnd);
|
||||||
|
|
||||||
|
// set start and end points
|
||||||
|
if (range) { // IE
|
||||||
|
// setting end first makes calculations easier
|
||||||
|
range.collapse();
|
||||||
|
range.moveEnd('character', selEnd - startTab - (numTabs * tabLen) - selNewlines - preNewlines);
|
||||||
|
range.moveStart('character', selStart - preTab - preNewlines);
|
||||||
|
range.select();
|
||||||
|
} else {
|
||||||
|
// set start first for Opera
|
||||||
|
this.element.selectionStart = selStart - preTab; // preTab is 0 or tabLen
|
||||||
|
// move the selection end over by the total number of tabs removed
|
||||||
|
this.element.selectionEnd = selEnd - startTab - (numTabs * tabLen);
|
||||||
|
}
|
||||||
|
} else { // no shift key
|
||||||
|
numTabs = 1; // for the first tab
|
||||||
|
// insert tabs at the beginning of each line of the selection
|
||||||
|
this.element.value = text.slice(0, startLine) + tab + text.slice(startLine, selStart) +
|
||||||
|
sel.replace(/\n/g, function () {
|
||||||
|
numTabs += 1;
|
||||||
|
return '\n' + tab;
|
||||||
|
}) + text.slice(selEnd);
|
||||||
|
|
||||||
|
// set start and end points
|
||||||
|
if (range) { // IE
|
||||||
|
range.collapse();
|
||||||
|
range.moveEnd('character', selEnd + (numTabs * tabLen) - selNewlines - preNewlines);
|
||||||
|
range.moveStart('character', selStart + tabLen - preNewlines);
|
||||||
|
range.select();
|
||||||
|
} else {
|
||||||
|
// the selection start is always moved by 1 character
|
||||||
|
this.element.selectionStart = selStart + (selStart == startLine ? 0 : tabLen);
|
||||||
|
// move the selection end over by the total number of tabs inserted
|
||||||
|
this.element.selectionEnd = selEnd + (numTabs * tabLen);
|
||||||
|
this.element.scrollTop = initScrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // single line selection
|
||||||
|
// if the shift key was pressed, remove a tab instead of inserting one
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// if the character before the selection is a tab, remove it
|
||||||
|
if (text.slice(selStart - tabLen).indexOf(tab) === 0) {
|
||||||
|
this.element.value = text.slice(0, selStart - tabLen) + text.slice(selStart);
|
||||||
|
|
||||||
|
// set start and end points
|
||||||
|
if (range) { // IE
|
||||||
|
// collapses range and moves it by -1 tab
|
||||||
|
range.move('character', selStart - tabLen - preNewlines);
|
||||||
|
range.select();
|
||||||
|
} else {
|
||||||
|
this.element.selectionEnd = this.element.selectionStart = selStart - tabLen;
|
||||||
|
this.element.scrollTop = initScrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // no shift key - insert a tab
|
||||||
|
if (range) { // IE
|
||||||
|
range.text = tab;
|
||||||
|
range.select();
|
||||||
|
} else {
|
||||||
|
this.element.value = text.slice(0, selStart) + tab + text.slice(selEnd);
|
||||||
|
this.element.selectionEnd = this.element.selectionStart = selStart + tabLen;
|
||||||
|
this.element.scrollTop = initScrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.autoIndent) { // Enter key
|
||||||
|
// insert a newline and copy the whitespace from the beginning of the line
|
||||||
|
// find the start of the first selected line
|
||||||
|
if (selStart === 0 || text.charAt(selStart - 1) === '\n') {
|
||||||
|
// the selection starts at the beginning of a line
|
||||||
|
// do nothing special
|
||||||
|
this.inWhitespace = true;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// see explanation under "multi-line selection" above
|
||||||
|
startLine = text.lastIndexOf('\n', selStart - 1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the end of the first selected line
|
||||||
|
endLine = text.indexOf('\n', selStart);
|
||||||
|
|
||||||
|
// if no newline is found, set endLine to the end of the text
|
||||||
|
if (endLine === -1) {
|
||||||
|
endLine = text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the whitespace at the beginning of the first selected line (spaces and tabs only)
|
||||||
|
whitespace = text.slice(startLine, endLine).match(/^[ \t]*/)[0];
|
||||||
|
whitespaceLen = whitespace.length;
|
||||||
|
|
||||||
|
// the cursor (selStart) is in the whitespace at beginning of the line
|
||||||
|
// do nothing special
|
||||||
|
if (selStart < startLine + whitespaceLen) {
|
||||||
|
this.inWhitespace = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range) { // IE
|
||||||
|
// insert the newline and whitespace
|
||||||
|
range.text = '\n' + whitespace;
|
||||||
|
range.select();
|
||||||
|
} else {
|
||||||
|
// insert the newline and whitespace
|
||||||
|
this.element.value = text.slice(0, selStart) + '\n' + whitespace + text.slice(selEnd);
|
||||||
|
// Opera uses \r\n for a newline, instead of \n,
|
||||||
|
// so use newlineLen instead of a hard-coded value
|
||||||
|
this.element.selectionEnd = this.element.selectionStart = selStart + this.newlineLen + whitespaceLen;
|
||||||
|
this.element.scrollTop = initScrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
11
assets/stylesheets/style.css
Normal file
11
assets/stylesheets/style.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
#admin-menu a.custom-workflows {
|
||||||
|
background-image: url(../../../images/ticket_go.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.list.custom-workflows td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#custom_workflow_description, #custom_workflow_script {
|
||||||
|
width: 99%;
|
||||||
|
}
|
||||||
23
config/locales/en.yml
Normal file
23
config/locales/en.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
en:
|
||||||
|
project_module_custom_workflows_module: "Custom workflows"
|
||||||
|
permission_manage_project_workflow: "Manage project custom workflows"
|
||||||
|
label_custom_workflow: "Custom workflows"
|
||||||
|
label_custom_workflow_plural: "Custom workflows"
|
||||||
|
label_custom_workflow_new: "Create a custom workflow"
|
||||||
|
|
||||||
|
field_script: "Workflow script"
|
||||||
|
field_is_enabled: "Enabled"
|
||||||
|
|
||||||
|
field_custom_workflow:
|
||||||
|
script: "Workflow script"
|
||||||
|
|
||||||
|
|
||||||
|
activerecord:
|
||||||
|
errors:
|
||||||
|
messages:
|
||||||
|
invalid_script: "contains error: %{error}"
|
||||||
|
custom_workflow_error: "Custom workflow error (please contact administrator)"
|
||||||
|
new_status_invalid: "transition from '%{old_status}' to '%{new_status}' is prohibited"
|
||||||
|
|
||||||
|
text_select_project_custom_workflows: Select project custom workflows
|
||||||
|
text_custom_workflow_script_note: Use the variable @issue for an access to the issue. To finish the process with an error, use raise WorkflowError, "Message to user"
|
||||||
23
config/locales/ru.yml
Normal file
23
config/locales/ru.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
ru:
|
||||||
|
project_module_custom_workflows_module: "Пользовательские рабочие процессы"
|
||||||
|
permission_manage_project_workflow: "Управление пользовательскими рабочими процессами в проекте"
|
||||||
|
label_custom_workflow: "Пользовательский рабочий процесс"
|
||||||
|
label_custom_workflow_plural: "Пользовательские рабочие процессы"
|
||||||
|
label_custom_workflow_new: "Новый процесс"
|
||||||
|
|
||||||
|
field_script: "Скрипт управления"
|
||||||
|
field_is_enabled: "Разрешено"
|
||||||
|
|
||||||
|
field_custom_workflow:
|
||||||
|
script: "Скрипт управления"
|
||||||
|
|
||||||
|
|
||||||
|
activerecord:
|
||||||
|
errors:
|
||||||
|
messages:
|
||||||
|
invalid_script: "содержит ошибку: %{error}"
|
||||||
|
custom_workflow_error: "Ошибка в рабочем процессе (обратитесь к администратору)"
|
||||||
|
new_status_invalid: "- переход от '%{old_status}' к '%{new_status}' невозможен"
|
||||||
|
|
||||||
|
text_select_project_custom_workflows: Выберите процессы для данного проекта
|
||||||
|
text_custom_workflow_script_note: Используйте переменную @issue для доступа к задаче. Для завершения процесса с ошибкой используйте исключение WorkflowError.
|
||||||
9
config/routes.rb
Normal file
9
config/routes.rb
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
if Redmine::VERSION::MAJOR >= 2
|
||||||
|
RedmineApp::Application.routes.draw do
|
||||||
|
resources :custom_workflows
|
||||||
|
end
|
||||||
|
else
|
||||||
|
ActionController::Routing::Routes.draw do |map|
|
||||||
|
map.resources :custom_workflows
|
||||||
|
end
|
||||||
|
end
|
||||||
14
db/migrate/20110915084858_create_custom_workflows.rb
Normal file
14
db/migrate/20110915084858_create_custom_workflows.rb
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
class CreateCustomWorkflows < ActiveRecord::Migration
|
||||||
|
def self.up
|
||||||
|
create_table :custom_workflows, :force => true do |t|
|
||||||
|
t.references :project
|
||||||
|
t.text :script
|
||||||
|
t.boolean :is_enabled
|
||||||
|
end
|
||||||
|
add_index :custom_workflows, [:project_id], :unique => true
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
drop_table :custom_workflows
|
||||||
|
end
|
||||||
|
end
|
||||||
13
db/migrate/20120601054047_alter_custom_workflows.rb
Normal file
13
db/migrate/20120601054047_alter_custom_workflows.rb
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
class AlterCustomWorkflows < ActiveRecord::Migration
|
||||||
|
def self.up
|
||||||
|
remove_column :custom_workflows, :project_id
|
||||||
|
remove_column :custom_workflows, :is_enabled
|
||||||
|
add_column :custom_workflows, :name, :string, :null => false
|
||||||
|
add_column :custom_workflows, :description, :string, :null => false
|
||||||
|
add_column :custom_workflows, :position, :integer, :null => false, :default => 1
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
raise ActiveRecord::IrreversibleMigration
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
class CreateCustomWorkflowsProjects < ActiveRecord::Migration
|
||||||
|
def self.up
|
||||||
|
create_table :custom_workflows_projects, :force => true, :id => false do |t|
|
||||||
|
t.references :project
|
||||||
|
t.references :custom_workflow
|
||||||
|
end
|
||||||
|
add_index :custom_workflows_projects, [:project_id]
|
||||||
|
add_index :custom_workflows_projects, [:custom_workflow_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
drop_table :custom_workflows_projects
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
class ChangeCustomWorkflowsDescriptionType < ActiveRecord::Migration
|
||||||
|
def self.up
|
||||||
|
change_column :custom_workflows, :description, :text, :null => false
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
change_column :custom_workflows, :description, :string, :null => false
|
||||||
|
end
|
||||||
|
end
|
||||||
33
db/migrate/20120831064944_create_example_workflow.rb
Normal file
33
db/migrate/20120831064944_create_example_workflow.rb
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
class CreateExampleWorkflow < ActiveRecord::Migration
|
||||||
|
def self.up
|
||||||
|
CustomWorkflow.create(:name => "Duration/Done Ratio/Status correlation", :description => <<EOD, :script => <<EOS)
|
||||||
|
Set up a correlation between the start date, due date, done ratio and status of issues.
|
||||||
|
|
||||||
|
* If done ratio is changed to 100% and status is "In Process", status changes to "Resolved"
|
||||||
|
* If status is "New", "Resolved" or "Feedback" and done ratio is changed to value less than 100%, status changes to "In process"
|
||||||
|
* If status is changed to "In process" and start date is not set, then it sets to current date
|
||||||
|
* If status is changed to "Resolved" and end date is not set, then it set to due date
|
||||||
|
|
||||||
|
To use this script properly, turn off "Use current date as start date for new issues" option in the settings as this script already do it own way.
|
||||||
|
EOD
|
||||||
|
if @issue.done_ratio_changed?
|
||||||
|
if @issue.done_ratio==100 && @issue.status_id=2
|
||||||
|
@issue.status_id=3
|
||||||
|
elsif [1,3,4].include?(@issue.status_id) && @issue.done_ratio<100
|
||||||
|
@issue.status_id=2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if @issue.status_id_changed?
|
||||||
|
if @issue.status_id==2
|
||||||
|
@issue.start_date ||= Time.now
|
||||||
|
end
|
||||||
|
if @issue.status_id==3
|
||||||
|
@issue.done_ratio = 100
|
||||||
|
@issue.start_date ||= @issue.created_on
|
||||||
|
@issue.due_date ||= Time.now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
EOS
|
||||||
|
end
|
||||||
|
end
|
||||||
35
init.rb
Normal file
35
init.rb
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
require 'redmine_custom_workflows/hooks'
|
||||||
|
|
||||||
|
to_prepare = Proc.new do
|
||||||
|
unless Project.included_modules.include?(RedmineCustomWorkflows::ProjectPatch)
|
||||||
|
Project.send(:include, RedmineCustomWorkflows::ProjectPatch)
|
||||||
|
end
|
||||||
|
unless ProjectsHelper.included_modules.include?(RedmineCustomWorkflows::ProjectsHelperPatch)
|
||||||
|
ProjectsHelper.send(:include, RedmineCustomWorkflows::ProjectsHelperPatch)
|
||||||
|
end
|
||||||
|
unless Issue.included_modules.include?(RedmineCustomWorkflows::IssuePatch)
|
||||||
|
Issue.send(:include, RedmineCustomWorkflows::IssuePatch)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if Redmine::VERSION::MAJOR >= 2
|
||||||
|
Rails.configuration.to_prepare(&to_prepare)
|
||||||
|
else
|
||||||
|
require 'dispatcher'
|
||||||
|
Dispatcher.to_prepare(:redmine_custom_workflows, &to_prepare)
|
||||||
|
end
|
||||||
|
|
||||||
|
Redmine::Plugin.register :redmine_custom_workflows do
|
||||||
|
name 'Redmine Custom Workflow plugin'
|
||||||
|
author 'Anton Argirov'
|
||||||
|
description 'Allows to create custom workflows for issues, defined in the plain Ruby language'
|
||||||
|
version '0.0.1'
|
||||||
|
url 'http://redmine.academ.org'
|
||||||
|
|
||||||
|
menu :admin_menu, :custom_workflows, {:controller => 'custom_workflows', :action => 'index'}, :caption => :label_custom_workflow_plural
|
||||||
|
|
||||||
|
project_module :custom_workflows_module do
|
||||||
|
permission :manage_project_workflow, {}, :require => :member
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
7
lib/redmine_custom_workflows/hooks.rb
Normal file
7
lib/redmine_custom_workflows/hooks.rb
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module RedmineCustomWorkflows
|
||||||
|
class Hooks < Redmine::Hook::ViewListener
|
||||||
|
def view_layouts_base_html_head(context)
|
||||||
|
stylesheet_link_tag :style, :plugin => 'redmine_custom_workflows'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
44
lib/redmine_custom_workflows/issue_patch.rb
Normal file
44
lib/redmine_custom_workflows/issue_patch.rb
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
module RedmineCustomWorkflows
|
||||||
|
module IssuePatch
|
||||||
|
unloadable
|
||||||
|
|
||||||
|
def self.included(base)
|
||||||
|
base.send(:include, InstanceMethods)
|
||||||
|
base.class_eval do
|
||||||
|
before_save :custom_workflow_eval
|
||||||
|
validate :validate_status
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module InstanceMethods
|
||||||
|
def validate_status
|
||||||
|
if status_id_was != status_id && !new_statuses_allowed_to(User.current, new_record?).collect(&:id).include?(status_id)
|
||||||
|
status_was = IssueStatus.find_by_id(status_id_was)
|
||||||
|
status_new = IssueStatus.find_by_id(status_id)
|
||||||
|
|
||||||
|
errors.add :status, :new_status_invalid,
|
||||||
|
:old_status => status_was && status_was.name,
|
||||||
|
:new_status => status_new && status_new.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def custom_workflow_eval
|
||||||
|
return true unless project && project.module_enabled?(:custom_workflows_module)
|
||||||
|
saved_attributes = attributes.dup
|
||||||
|
project.custom_workflows.each do |workflow|
|
||||||
|
begin
|
||||||
|
workflow.eval_script(:issue => self)
|
||||||
|
rescue WorkflowError => e
|
||||||
|
errors.add :base, e.error
|
||||||
|
return false
|
||||||
|
rescue Exception => e
|
||||||
|
Rails.logger.warn e
|
||||||
|
errors.add :base, :custom_workflow_error
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
saved_attributes == attributes || valid?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
14
lib/redmine_custom_workflows/project_patch.rb
Normal file
14
lib/redmine_custom_workflows/project_patch.rb
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
module RedmineCustomWorkflows
|
||||||
|
module ProjectPatch
|
||||||
|
unloadable
|
||||||
|
|
||||||
|
def self.included(base)
|
||||||
|
base.class_eval do
|
||||||
|
has_and_belongs_to_many :custom_workflows
|
||||||
|
#accepts_nested_attributes_for :custom_workflow, :update_only => true
|
||||||
|
safe_attributes :custom_workflow_ids, :if =>
|
||||||
|
lambda { |project, user| project.new_record? || user.allowed_to?(:manage_project_workflow, project) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
21
lib/redmine_custom_workflows/projects_helper_patch.rb
Normal file
21
lib/redmine_custom_workflows/projects_helper_patch.rb
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
module RedmineCustomWorkflows
|
||||||
|
module ProjectsHelperPatch
|
||||||
|
unloadable
|
||||||
|
|
||||||
|
def self.included(base)
|
||||||
|
base.send(:include, InstanceMethods)
|
||||||
|
base.class_eval do
|
||||||
|
alias_method_chain :project_settings_tabs, :custom_workflows
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module InstanceMethods
|
||||||
|
def project_settings_tabs_with_custom_workflows
|
||||||
|
tabs = project_settings_tabs_without_custom_workflows
|
||||||
|
tabs << {:name => 'custom_workflows', :action => :manage_project_workflow, :partial => 'projects/settings/custom_workflow',
|
||||||
|
:label => :label_custom_workflow_plural} if User.current.allowed_to?(:manage_project_workflow, @project)
|
||||||
|
tabs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
x
Reference in New Issue
Block a user