##// END OF EJS Templates
Forums enhancements:...
Jean-Philippe Lang -
r913:29b3614bcb75
parent child
Show More
@@ -0,0 +1,6
1 <h2><%= link_to h(@board.name), :controller => 'boards', :action => 'show', :project_id => @project, :id => @board %> &#187; <%=h @message.subject %></h2>
2
3 <% form_for :message, @message, :url => {:action => 'edit'}, :html => {:multipart => true} do |f| %>
4 <%= render :partial => 'form', :locals => {:f => f} %>
5 <%= submit_tag l(:button_save) %>
6 <% end %>
@@ -0,0 +1,9
1 class AddMessagesLocked < ActiveRecord::Migration
2 def self.up
3 add_column :messages, :locked, :boolean, :default => false
4 end
5
6 def self.down
7 remove_column :messages, :locked
8 end
9 end
@@ -0,0 +1,9
1 class AddMessagesSticky < ActiveRecord::Migration
2 def self.up
3 add_column :messages, :sticky, :integer, :default => 0
4 end
5
6 def self.down
7 remove_column :messages, :sticky
8 end
9 end
1 NO CONTENT: new file 100644, binary diff hidden
@@ -0,0 +1,50
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.dirname(__FILE__) + '/../test_helper'
19 require 'boards_controller'
20
21 # Re-raise errors caught by the controller.
22 class BoardsController; def rescue_action(e) raise e end; end
23
24 class BoardsControllerTest < Test::Unit::TestCase
25 fixtures :projects, :users, :members, :roles, :boards, :messages, :enabled_modules
26
27 def setup
28 @controller = BoardsController.new
29 @request = ActionController::TestRequest.new
30 @response = ActionController::TestResponse.new
31 User.current = nil
32 end
33
34 def test_index
35 get :index, :project_id => 1
36 assert_response :success
37 assert_template 'index'
38 assert_not_nil assigns(:boards)
39 assert_not_nil assigns(:project)
40 end
41
42 def test_show
43 get :show, :project_id => 1, :id => 1
44 assert_response :success
45 assert_template 'show'
46 assert_not_nil assigns(:board)
47 assert_not_nil assigns(:project)
48 assert_not_nil assigns(:topics)
49 end
50 end
@@ -0,0 +1,49
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.dirname(__FILE__) + '/../test_helper'
19 require 'messages_controller'
20
21 # Re-raise errors caught by the controller.
22 class MessagesController; def rescue_action(e) raise e end; end
23
24 class MessagesControllerTest < Test::Unit::TestCase
25 fixtures :projects, :users, :members, :roles, :boards, :messages, :enabled_modules
26
27 def setup
28 @controller = MessagesController.new
29 @request = ActionController::TestRequest.new
30 @response = ActionController::TestResponse.new
31 User.current = nil
32 end
33
34 def test_show
35 get :show, :board_id => 1, :id => 1
36 assert_response :success
37 assert_template 'show'
38 assert_not_nil assigns(:board)
39 assert_not_nil assigns(:project)
40 assert_not_nil assigns(:topic)
41 end
42
43 def test_reply
44 @request.session[:user_id] = 2
45 post :reply, :board_id => 1, :id => 1, :reply => { :content => 'This is a test reply', :subject => 'Test reply' }
46 assert_redirected_to 'messages/show'
47 assert Message.find_by_subject('Test reply')
48 end
49 end
@@ -1,86 +1,86
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class BoardsController < ApplicationController
19 19 layout 'base'
20 20 before_filter :find_project, :authorize
21 21
22 22 helper :messages
23 23 include MessagesHelper
24 24 helper :sort
25 25 include SortHelper
26 26 helper :watchers
27 27 include WatchersHelper
28 28
29 29 def index
30 30 @boards = @project.boards
31 31 # show the board if there is only one
32 32 if @boards.size == 1
33 33 @board = @boards.first
34 34 show
35 35 end
36 36 end
37 37
38 38 def show
39 39 sort_init "#{Message.table_name}.updated_on", "desc"
40 40 sort_update
41 41
42 42 @topic_count = @board.topics.count
43 43 @topic_pages = Paginator.new self, @topic_count, 25, params['page']
44 @topics = @board.topics.find :all, :order => sort_clause,
44 @topics = @board.topics.find :all, :order => "#{Message.table_name}.sticky DESC, #{sort_clause}",
45 45 :include => [:author, {:last_reply => :author}],
46 46 :limit => @topic_pages.items_per_page,
47 47 :offset => @topic_pages.current.offset
48 48 render :action => 'show', :layout => !request.xhr?
49 49 end
50 50
51 51 verify :method => :post, :only => [ :destroy ], :redirect_to => { :action => :index }
52 52
53 53 def new
54 54 @board = Board.new(params[:board])
55 55 @board.project = @project
56 56 if request.post? && @board.save
57 57 flash[:notice] = l(:notice_successful_create)
58 58 redirect_to :controller => 'projects', :action => 'settings', :id => @project, :tab => 'boards'
59 59 end
60 60 end
61 61
62 62 def edit
63 63 if request.post? && @board.update_attributes(params[:board])
64 64 case params[:position]
65 65 when 'highest'; @board.move_to_top
66 66 when 'higher'; @board.move_higher
67 67 when 'lower'; @board.move_lower
68 68 when 'lowest'; @board.move_to_bottom
69 69 end if params[:position]
70 70 redirect_to :controller => 'projects', :action => 'settings', :id => @project, :tab => 'boards'
71 71 end
72 72 end
73 73
74 74 def destroy
75 75 @board.destroy
76 76 redirect_to :controller => 'projects', :action => 'settings', :id => @project, :tab => 'boards'
77 77 end
78 78
79 79 private
80 80 def find_project
81 81 @project = Project.find(params[:project_id])
82 82 @board = @project.boards.find(params[:id]) if params[:id]
83 83 rescue ActiveRecord::RecordNotFound
84 84 render_404
85 85 end
86 86 end
@@ -1,65 +1,104
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class MessagesController < ApplicationController
19 19 layout 'base'
20 before_filter :find_project, :authorize
20 before_filter :find_board, :only => :new
21 before_filter :find_message, :except => :new
22 before_filter :authorize
21 23
22 24 verify :method => :post, :only => [ :reply, :destroy ], :redirect_to => { :action => :show }
23 25
24 26 helper :attachments
25 27 include AttachmentsHelper
26 28
29 # Show a topic and its replies
27 30 def show
28 31 @reply = Message.new(:subject => "RE: #{@message.subject}")
29 32 render :action => "show", :layout => false if request.xhr?
30 33 end
31 34
35 # Create a new topic
32 36 def new
33 37 @message = Message.new(params[:message])
34 38 @message.author = User.current
35 @message.board = @board
39 @message.board = @board
40 if params[:message] && User.current.allowed_to?(:edit_messages, @project)
41 @message.locked = params[:message]['locked']
42 @message.sticky = params[:message]['sticky']
43 end
36 44 if request.post? && @message.save
37 45 params[:attachments].each { |file|
38 46 Attachment.create(:container => @message, :file => file, :author => User.current) if file.size > 0
39 47 } if params[:attachments] and params[:attachments].is_a? Array
40 48 redirect_to :action => 'show', :id => @message
41 49 end
42 50 end
43 51
52 # Reply to a topic
44 53 def reply
45 54 @reply = Message.new(params[:reply])
46 55 @reply.author = User.current
47 56 @reply.board = @board
48 @message.children << @reply
57 @topic.children << @reply
49 58 if !@reply.new_record?
50 59 params[:attachments].each { |file|
51 60 Attachment.create(:container => @reply, :file => file, :author => User.current) if file.size > 0
52 61 } if params[:attachments] and params[:attachments].is_a? Array
53 62 end
54 redirect_to :action => 'show', :id => @message
63 redirect_to :action => 'show', :id => @topic
64 end
65
66 # Edit a message
67 def edit
68 if params[:message] && User.current.allowed_to?(:edit_messages, @project)
69 @message.locked = params[:message]['locked']
70 @message.sticky = params[:message]['sticky']
71 end
72 if request.post? && @message.update_attributes(params[:message])
73 params[:attachments].each { |file|
74 Attachment.create(:container => @message, :file => file, :author => User.current) if file.size > 0
75 } if params[:attachments] and params[:attachments].is_a? Array
76 flash[:notice] = l(:notice_successful_update)
77 redirect_to :action => 'show', :id => @topic
78 end
79 end
80
81 # Delete a messages
82 def destroy
83 @message.destroy
84 redirect_to @message.parent.nil? ?
85 { :controller => 'boards', :action => 'show', :project_id => @project, :id => @board } :
86 { :action => 'show', :id => @message.parent }
55 87 end
56 88
57 89 private
58 def find_project
90 def find_message
91 find_board
92 @message = @board.messages.find(params[:id], :include => :parent)
93 @topic = @message.root
94 rescue ActiveRecord::RecordNotFound
95 render_404
96 end
97
98 def find_board
59 99 @board = Board.find(params[:board_id], :include => :project)
60 100 @project = @board.project
61 @message = @board.topics.find(params[:id]) if params[:id]
62 101 rescue ActiveRecord::RecordNotFound
63 102 render_404
64 103 end
65 104 end
@@ -1,403 +1,403
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module ApplicationHelper
19 19 include Redmine::WikiFormatting::Macros::Definitions
20 20
21 21 def current_role
22 22 @current_role ||= User.current.role_for_project(@project)
23 23 end
24 24
25 25 # Return true if user is authorized for controller/action, otherwise false
26 26 def authorize_for(controller, action)
27 27 User.current.allowed_to?({:controller => controller, :action => action}, @project)
28 28 end
29 29
30 30 # Display a link if user is authorized
31 31 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
32 32 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
33 33 end
34 34
35 35 # Display a link to user's account page
36 36 def link_to_user(user)
37 link_to user.name, :controller => 'account', :action => 'show', :id => user
37 user ? link_to(user, :controller => 'account', :action => 'show', :id => user) : 'Anonymous'
38 38 end
39 39
40 40 def link_to_issue(issue)
41 41 link_to "#{issue.tracker.name} ##{issue.id}", :controller => "issues", :action => "show", :id => issue
42 42 end
43 43
44 44 def toggle_link(name, id, options={})
45 45 onclick = "Element.toggle('#{id}'); "
46 46 onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
47 47 onclick << "return false;"
48 48 link_to(name, "#", :onclick => onclick)
49 49 end
50 50
51 51 def show_and_goto_link(name, id, options={})
52 52 onclick = "Element.show('#{id}'); "
53 53 onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
54 54 onclick << "location.href='##{id}-anchor'; "
55 55 onclick << "return false;"
56 56 link_to(name, "#", options.merge(:onclick => onclick))
57 57 end
58 58
59 59 def image_to_function(name, function, html_options = {})
60 60 html_options.symbolize_keys!
61 61 tag(:input, html_options.merge({
62 62 :type => "image", :src => image_path(name),
63 63 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
64 64 }))
65 65 end
66 66
67 67 def prompt_to_remote(name, text, param, url, html_options = {})
68 68 html_options[:onclick] = "promptToRemote('#{text}', '#{param}', '#{url_for(url)}'); return false;"
69 69 link_to name, {}, html_options
70 70 end
71 71
72 72 def format_date(date)
73 73 return nil unless date
74 74 # "Setting.date_format.size < 2" is a temporary fix (content of date_format setting changed)
75 75 @date_format ||= (Setting.date_format.blank? || Setting.date_format.size < 2 ? l(:general_fmt_date) : Setting.date_format)
76 76 date.strftime(@date_format)
77 77 end
78 78
79 79 def format_time(time, include_date = true)
80 80 return nil unless time
81 81 time = time.to_time if time.is_a?(String)
82 82 zone = User.current.time_zone
83 83 if time.utc?
84 84 local = zone ? zone.adjust(time) : time.getlocal
85 85 else
86 86 local = zone ? zone.adjust(time.getutc) : time
87 87 end
88 88 @date_format ||= (Setting.date_format.blank? || Setting.date_format.size < 2 ? l(:general_fmt_date) : Setting.date_format)
89 89 @time_format ||= (Setting.time_format.blank? ? l(:general_fmt_time) : Setting.time_format)
90 90 include_date ? local.strftime("#{@date_format} #{@time_format}") : local.strftime(@time_format)
91 91 end
92 92
93 93 def authoring(created, author)
94 94 time_tag = content_tag('acronym', distance_of_time_in_words(Time.now, created), :title => format_time(created))
95 l(:label_added_time_by, author.name, time_tag)
95 l(:label_added_time_by, author || 'Anonymous', time_tag)
96 96 end
97 97
98 98 def day_name(day)
99 99 l(:general_day_names).split(',')[day-1]
100 100 end
101 101
102 102 def month_name(month)
103 103 l(:actionview_datehelper_select_month_names).split(',')[month-1]
104 104 end
105 105
106 106 def pagination_links_full(paginator, options={}, html_options={})
107 107 page_param = options.delete(:page_param) || :page
108 108
109 109 html = ''
110 110 html << link_to_remote(('&#171; ' + l(:label_previous)),
111 111 {:update => "content", :url => options.merge(page_param => paginator.current.previous)},
112 112 {:href => url_for(:params => options.merge(page_param => paginator.current.previous))}) + ' ' if paginator.current.previous
113 113
114 114 html << (pagination_links_each(paginator, options) do |n|
115 115 link_to_remote(n.to_s,
116 116 {:url => {:params => options.merge(page_param => n)}, :update => 'content'},
117 117 {:href => url_for(:params => options.merge(page_param => n))})
118 118 end || '')
119 119
120 120 html << ' ' + link_to_remote((l(:label_next) + ' &#187;'),
121 121 {:update => "content", :url => options.merge(page_param => paginator.current.next)},
122 122 {:href => url_for(:params => options.merge(page_param => paginator.current.next))}) if paginator.current.next
123 123 html
124 124 end
125 125
126 126 def set_html_title(text)
127 127 @html_header_title = text
128 128 end
129 129
130 130 def html_title
131 131 title = []
132 132 title << @project.name if @project
133 133 title << @html_header_title
134 134 title << Setting.app_title
135 135 title.compact.join(' - ')
136 136 end
137 137
138 138 ACCESSKEYS = {:edit => 'e',
139 139 :preview => 'r',
140 140 :quick_search => 'f',
141 141 :search => '4',
142 142 }.freeze unless const_defined?(:ACCESSKEYS)
143 143
144 144 def accesskey(s)
145 145 ACCESSKEYS[s]
146 146 end
147 147
148 148 # Formats text according to system settings.
149 149 # 2 ways to call this method:
150 150 # * with a String: textilizable(text, options)
151 151 # * with an object and one of its attribute: textilizable(issue, :description, options)
152 152 def textilizable(*args)
153 153 options = args.last.is_a?(Hash) ? args.pop : {}
154 154 case args.size
155 155 when 1
156 156 obj = nil
157 157 text = args.shift || ''
158 158 when 2
159 159 obj = args.shift
160 160 text = obj.send(args.shift)
161 161 else
162 162 raise ArgumentError, 'invalid arguments to textilizable'
163 163 end
164 164
165 165 # when using an image link, try to use an attachment, if possible
166 166 attachments = options[:attachments]
167 167 if attachments
168 168 text = text.gsub(/!([<>=]*)(\S+\.(gif|jpg|jpeg|png))!/) do |m|
169 169 align = $1
170 170 filename = $2
171 171 rf = Regexp.new(filename, Regexp::IGNORECASE)
172 172 # search for the picture in attachments
173 173 if found = attachments.detect { |att| att.filename =~ rf }
174 174 image_url = url_for :controller => 'attachments', :action => 'download', :id => found.id
175 175 "!#{align}#{image_url}!"
176 176 else
177 177 "!#{align}#{filename}!"
178 178 end
179 179 end
180 180 end
181 181
182 182 text = (Setting.text_formatting == 'textile') ?
183 183 Redmine::WikiFormatting.to_html(text) { |macro, args| exec_macro(macro, obj, args) } :
184 184 simple_format(auto_link(h(text)))
185 185
186 186 # different methods for formatting wiki links
187 187 case options[:wiki_links]
188 188 when :local
189 189 # used for local links to html files
190 190 format_wiki_link = Proc.new {|project, title| "#{title}.html" }
191 191 when :anchor
192 192 # used for single-file wiki export
193 193 format_wiki_link = Proc.new {|project, title| "##{title}" }
194 194 else
195 195 format_wiki_link = Proc.new {|project, title| url_for :controller => 'wiki', :action => 'index', :id => project, :page => title }
196 196 end
197 197
198 198 project = options[:project] || @project
199 199
200 200 # turn wiki links into html links
201 201 # example:
202 202 # [[mypage]]
203 203 # [[mypage|mytext]]
204 204 # wiki links can refer other project wikis, using project name or identifier:
205 205 # [[project:]] -> wiki starting page
206 206 # [[project:|mytext]]
207 207 # [[project:mypage]]
208 208 # [[project:mypage|mytext]]
209 209 text = text.gsub(/\[\[([^\]\|]+)(\|([^\]\|]+))?\]\]/) do |m|
210 210 link_project = project
211 211 page = $1
212 212 title = $3
213 213 if page =~ /^([^\:]+)\:(.*)$/
214 214 link_project = Project.find_by_name($1) || Project.find_by_identifier($1)
215 215 page = title || $2
216 216 title = $1 if page.blank?
217 217 end
218 218
219 219 if link_project && link_project.wiki
220 220 # check if page exists
221 221 wiki_page = link_project.wiki.find_page(page)
222 222 link_to((title || page), format_wiki_link.call(link_project, Wiki.titleize(page)),
223 223 :class => ('wiki-page' + (wiki_page ? '' : ' new')))
224 224 else
225 225 # project or wiki doesn't exist
226 226 title || page
227 227 end
228 228 end
229 229
230 230 # turn issue and revision ids into links
231 231 # example:
232 232 # #52 -> <a href="/issues/show/52">#52</a>
233 233 # r52 -> <a href="/repositories/revision/6?rev=52">r52</a> (project.id is 6)
234 234 text = text.gsub(%r{([\s\(,-^])(#|r)(\d+)(?=[[:punct:]]|\s|<|$)}) do |m|
235 235 leading, otype, oid = $1, $2, $3
236 236 link = nil
237 237 if otype == 'r'
238 238 if project && (changeset = project.changesets.find_by_revision(oid))
239 239 link = link_to("r#{oid}", {:controller => 'repositories', :action => 'revision', :id => project.id, :rev => oid}, :class => 'changeset',
240 240 :title => truncate(changeset.comments, 100))
241 241 end
242 242 else
243 243 if issue = Issue.find_by_id(oid.to_i, :include => [:project, :status], :conditions => Project.visible_by(User.current))
244 244 link = link_to("##{oid}", {:controller => 'issues', :action => 'show', :id => oid}, :class => 'issue',
245 245 :title => "#{truncate(issue.subject, 100)} (#{issue.status.name})")
246 246 link = content_tag('del', link) if issue.closed?
247 247 end
248 248 end
249 249 leading + (link || "#{otype}#{oid}")
250 250 end
251 251
252 252 text
253 253 end
254 254
255 255 # Same as Rails' simple_format helper without using paragraphs
256 256 def simple_format_without_paragraph(text)
257 257 text.to_s.
258 258 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
259 259 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
260 260 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />') # 1 newline -> br
261 261 end
262 262
263 263 def error_messages_for(object_name, options = {})
264 264 options = options.symbolize_keys
265 265 object = instance_variable_get("@#{object_name}")
266 266 if object && !object.errors.empty?
267 267 # build full_messages here with controller current language
268 268 full_messages = []
269 269 object.errors.each do |attr, msg|
270 270 next if msg.nil?
271 271 msg = msg.first if msg.is_a? Array
272 272 if attr == "base"
273 273 full_messages << l(msg)
274 274 else
275 275 full_messages << "&#171; " + (l_has_string?("field_" + attr) ? l("field_" + attr) : object.class.human_attribute_name(attr)) + " &#187; " + l(msg) unless attr == "custom_values"
276 276 end
277 277 end
278 278 # retrieve custom values error messages
279 279 if object.errors[:custom_values]
280 280 object.custom_values.each do |v|
281 281 v.errors.each do |attr, msg|
282 282 next if msg.nil?
283 283 msg = msg.first if msg.is_a? Array
284 284 full_messages << "&#171; " + v.custom_field.name + " &#187; " + l(msg)
285 285 end
286 286 end
287 287 end
288 288 content_tag("div",
289 289 content_tag(
290 290 options[:header_tag] || "span", lwr(:gui_validation_error, full_messages.length) + ":"
291 291 ) +
292 292 content_tag("ul", full_messages.collect { |msg| content_tag("li", msg) }),
293 293 "id" => options[:id] || "errorExplanation", "class" => options[:class] || "errorExplanation"
294 294 )
295 295 else
296 296 ""
297 297 end
298 298 end
299 299
300 300 def lang_options_for_select(blank=true)
301 301 (blank ? [["(auto)", ""]] : []) +
302 302 GLoc.valid_languages.collect{|lang| [ ll(lang.to_s, :general_lang_name), lang.to_s]}.sort{|x,y| x.first <=> y.first }
303 303 end
304 304
305 305 def label_tag_for(name, option_tags = nil, options = {})
306 306 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
307 307 content_tag("label", label_text)
308 308 end
309 309
310 310 def labelled_tabular_form_for(name, object, options, &proc)
311 311 options[:html] ||= {}
312 312 options[:html].store :class, "tabular"
313 313 form_for(name, object, options.merge({ :builder => TabularFormBuilder, :lang => current_language}), &proc)
314 314 end
315 315
316 316 def check_all_links(form_name)
317 317 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
318 318 " | " +
319 319 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
320 320 end
321 321
322 322 def progress_bar(pct, options={})
323 323 width = options[:width] || '100px;'
324 324 legend = options[:legend] || ''
325 325 content_tag('table',
326 326 content_tag('tr',
327 327 (pct > 0 ? content_tag('td', '', :width => "#{pct.floor}%;", :class => 'closed') : '') +
328 328 (pct < 100 ? content_tag('td', '', :width => "#{100-pct.floor}%;", :class => 'open') : '')
329 329 ), :class => 'progress', :style => "width: #{width};") +
330 330 content_tag('p', legend, :class => 'pourcent')
331 331 end
332 332
333 333 def context_menu_link(name, url, options={})
334 334 options[:class] ||= ''
335 335 if options.delete(:selected)
336 336 options[:class] << ' icon-checked disabled'
337 337 options[:disabled] = true
338 338 end
339 339 if options.delete(:disabled)
340 340 options.delete(:method)
341 341 options.delete(:confirm)
342 342 options.delete(:onclick)
343 343 options[:class] << ' disabled'
344 344 url = '#'
345 345 end
346 346 link_to name, url, options
347 347 end
348 348
349 349 def calendar_for(field_id)
350 350 image_tag("calendar.png", {:id => "#{field_id}_trigger",:class => "calendar-trigger"}) +
351 351 javascript_tag("Calendar.setup({inputField : '#{field_id}', ifFormat : '%Y-%m-%d', button : '#{field_id}_trigger' });")
352 352 end
353 353
354 354 def wikitoolbar_for(field_id)
355 355 return '' unless Setting.text_formatting == 'textile'
356 356 javascript_include_tag('jstoolbar') + javascript_tag("var toolbar = new jsToolBar($('#{field_id}')); toolbar.draw();")
357 357 end
358 358
359 359 def content_for(name, content = nil, &block)
360 360 @has_content ||= {}
361 361 @has_content[name] = true
362 362 super(name, content, &block)
363 363 end
364 364
365 365 def has_content?(name)
366 366 (@has_content && @has_content[name]) || false
367 367 end
368 368 end
369 369
370 370 class TabularFormBuilder < ActionView::Helpers::FormBuilder
371 371 include GLoc
372 372
373 373 def initialize(object_name, object, template, options, proc)
374 374 set_language_if_valid options.delete(:lang)
375 375 @object_name, @object, @template, @options, @proc = object_name, object, template, options, proc
376 376 end
377 377
378 378 (field_helpers - %w(radio_button hidden_field) + %w(date_select)).each do |selector|
379 379 src = <<-END_SRC
380 380 def #{selector}(field, options = {})
381 381 return super if options.delete :no_label
382 382 label_text = l(options[:label]) if options[:label]
383 383 label_text ||= l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym)
384 384 label_text << @template.content_tag("span", " *", :class => "required") if options.delete(:required)
385 385 label = @template.content_tag("label", label_text,
386 386 :class => (@object && @object.errors[field] ? "error" : nil),
387 387 :for => (@object_name.to_s + "_" + field.to_s))
388 388 label + super
389 389 end
390 390 END_SRC
391 391 class_eval src, __FILE__, __LINE__
392 392 end
393 393
394 394 def select(field, choices, options = {}, html_options = {})
395 395 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
396 396 label = @template.content_tag("label", label_text,
397 397 :class => (@object && @object.errors[field] ? "error" : nil),
398 398 :for => (@object_name.to_s + "_" + field.to_s))
399 399 label + super
400 400 end
401 401
402 402 end
403 403
@@ -1,49 +1,67
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Message < ActiveRecord::Base
19 19 belongs_to :board
20 20 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
21 21 acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
22 22 has_many :attachments, :as => :container, :dependent => :destroy
23 23 belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
24 24
25 25 acts_as_searchable :columns => ['subject', 'content'],
26 26 :include => :board,
27 27 :project_key => 'project_id',
28 28 :date_column => 'created_on'
29 29 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
30 30 :description => :content,
31 31 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id, :id => o.id}}
32 32
33 attr_protected :locked, :sticky
33 34 validates_presence_of :subject, :content
34 35 validates_length_of :subject, :maximum => 255
35 36
37 def validate_on_create
38 # Can not reply to a locked topic
39 errors.add_to_base 'Topic is locked' if root.locked?
40 end
41
36 42 def after_create
37 43 board.update_attribute(:last_message_id, self.id)
38 44 board.increment! :messages_count
39 45 if parent
40 46 parent.reload.update_attribute(:last_reply_id, self.id)
41 47 else
42 48 board.increment! :topics_count
43 49 end
44 50 end
45 51
52 def after_destroy
53 # The following line is required so that the previous counter
54 # updates (due to children removal) are not overwritten
55 board.reload
56 board.decrement! :messages_count
57 board.decrement! :topics_count unless parent
58 end
59
60 def sticky?
61 sticky == 1
62 end
63
46 64 def project
47 65 board.project
48 66 end
49 67 end
@@ -1,30 +1,30
1 1 <h2><%= l(:label_board_plural) %></h2>
2 2
3 3 <table class="list">
4 4 <thead><tr>
5 5 <th><%= l(:label_board) %></th>
6 6 <th><%= l(:label_topic_plural) %></th>
7 7 <th><%= l(:label_message_plural) %></th>
8 8 <th><%= l(:label_message_last) %></th>
9 9 </tr></thead>
10 10 <tbody>
11 11 <% for board in @boards %>
12 12 <tr class="<%= cycle 'odd', 'even' %>">
13 13 <td>
14 14 <%= link_to h(board.name), {:action => 'show', :id => board}, :class => "icon22 icon22-comment" %><br />
15 15 <%=h board.description %>
16 16 </td>
17 17 <td align="center"><%= board.topics_count %></td>
18 18 <td align="center"><%= board.messages_count %></td>
19 19 <td>
20 20 <small>
21 21 <% if board.last_message %>
22 <%= board.last_message.author.name %>, <%= format_time(board.last_message.created_on) %><br />
22 <%= authoring board.last_message.created_on, board.last_message.author %><br />
23 23 <%= link_to_message board.last_message %>
24 24 <% end %>
25 25 </small>
26 26 </td>
27 27 </tr>
28 28 <% end %>
29 29 </tbody>
30 30 </table>
@@ -1,52 +1,50
1 1 <div class="contextual">
2 2 <%= link_to_if_authorized l(:label_message_new),
3 3 {:controller => 'messages', :action => 'new', :board_id => @board},
4 4 :class => 'icon icon-add',
5 5 :onclick => 'Element.show("add-message"); return false;' %>
6 6 <%= watcher_tag(@board, User.current) %>
7 7 </div>
8 8
9 9 <div id="add-message" style="display:none;">
10 10 <h2><%= link_to h(@board.name), :controller => 'boards', :action => 'show', :project_id => @project, :id => @board %> &#187; <%= l(:label_message_new) %></h2>
11 11 <% form_for :message, @message, :url => {:controller => 'messages', :action => 'new', :board_id => @board}, :html => {:multipart => true} do |f| %>
12 12 <%= render :partial => 'messages/form', :locals => {:f => f} %>
13 13 <p><%= submit_tag l(:button_create) %>
14 14 <%= link_to l(:button_cancel), "#", :onclick => 'Element.hide("add-message")' %></p>
15 15 <% end %>
16 16 </div>
17 17
18 18 <h2><%=h @board.name %></h2>
19 19
20 20 <% if @topics.any? %>
21 <table class="list">
21 <table class="list messages">
22 22 <thead><tr>
23 23 <th><%= l(:field_subject) %></th>
24 24 <th><%= l(:field_author) %></th>
25 25 <%= sort_header_tag("#{Message.table_name}.created_on", :caption => l(:field_created_on)) %>
26 26 <th><%= l(:label_reply_plural) %></th>
27 27 <%= sort_header_tag("#{Message.table_name}.updated_on", :caption => l(:label_message_last)) %>
28 28 </tr></thead>
29 29 <tbody>
30 30 <% @topics.each do |topic| %>
31 <tr class="<%= cycle 'odd', 'even' %>">
32 <td><%= link_to h(topic.subject), :controller => 'messages', :action => 'show', :board_id => @board, :id => topic %></td>
33 <td align="center"><%= link_to_user topic.author %></td>
34 <td align="center"><%= format_time(topic.created_on) %></td>
35 <td align="center"><%= topic.replies_count %></td>
36 <td>
37 <small>
31 <tr class="message <%= cycle 'odd', 'even' %> <%= topic.sticky? ? 'sticky' : '' %> <%= topic.locked? ? 'locked' : '' %>">
32 <td class="subject"><%= link_to h(topic.subject), { :controller => 'messages', :action => 'show', :board_id => @board, :id => topic }, :class => 'icon' %></td>
33 <td class="author" align="center"><%= topic.author %></td>
34 <td class="created_on" align="center"><%= format_time(topic.created_on) %></td>
35 <td class="replies" align="center"><%= topic.replies_count %></td>
36 <td class="last_message">
38 37 <% if topic.last_reply %>
39 <%= topic.last_reply.author.name %>, <%= format_time(topic.last_reply.created_on) %><br />
38 <%= authoring topic.last_reply.created_on, topic.last_reply.author %><br />
40 39 <%= link_to_message topic.last_reply %>
41 40 <% end %>
42 </small>
43 41 </td>
44 42 </tr>
45 43 <% end %>
46 44 </tbody>
47 45 </table>
48 46 <p><%= pagination_links_full @topic_pages %>
49 47 [ <%= @topic_pages.current.first_item %> - <%= @topic_pages.current.last_item %> / <%= @topic_count %> ]</p>
50 48 <% else %>
51 49 <p class="nodata"><%= l(:label_no_data) %></p>
52 50 <% end %>
@@ -1,15 +1,21
1 1 <%= error_messages_for 'message' %>
2 2
3 3 <div class="box">
4 4 <!--[form:message]-->
5 5 <p><label><%= l(:field_subject) %></label><br />
6 <%= f.text_field :subject, :required => true, :size => 120 %></p>
6 <%= f.text_field :subject, :required => true, :size => 120 %>
7
8 <% if User.current.allowed_to?(:edit_messages, @project) %>
9 <label><%= f.check_box :sticky %> Sticky</label>
10 <label><%= f.check_box :locked %> Locked</label>
11 <% end %>
12 </p>
7 13
8 14 <p><%= f.text_area :content, :required => true, :cols => 80, :rows => 15, :class => 'wiki-edit', :id => 'message_content' %></p>
9 15 <%= wikitoolbar_for 'message_content' %>
10 16 <!--[eoform:message]-->
11 17
12 18 <span class="tabular">
13 19 <%= render :partial => 'attachments/form' %>
14 20 </span>
15 21 </div>
@@ -1,30 +1,39
1 <h2><%= link_to h(@board.name), :controller => 'boards', :action => 'show', :project_id => @project, :id => @board %> &#187; <%=h @message.subject %></h2>
1 <div class="contextual">
2 <%= link_to_if_authorized l(:button_edit), {:action => 'edit', :id => @topic}, :class => 'icon icon-edit' %>
3 <%= link_to_if_authorized l(:button_delete), {:action => 'destroy', :id => @topic}, :method => :post, :confirm => l(:text_are_you_sure), :class => 'icon icon-del' %>
4 </div>
5
6 <h2><%= link_to h(@board.name), :controller => 'boards', :action => 'show', :project_id => @project, :id => @board %> &#187; <%=h @topic.subject %></h2>
2 7
3 8 <div class="message">
4 <p><span class="author"><%= authoring @message.created_on, @message.author %></span></p>
9 <p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>
5 10 <div class="wiki">
6 <%= textilizable(@message.content, :attachments => @message.attachments) %>
11 <%= textilizable(@topic.content, :attachments => @topic.attachments) %>
7 12 </div>
8 <%= link_to_attachments @message.attachments, :no_author => true %>
13 <%= link_to_attachments @topic.attachments, :no_author => true %>
9 14 </div>
10 15 <br />
11 16
12 <div class="message reply">
13 17 <h3 class="icon22 icon22-comment"><%= l(:label_reply_plural) %></h3>
14 <% @message.children.each do |message| %>
18 <% @topic.children.each do |message| %>
15 19 <a name="<%= "message-#{message.id}" %>"></a>
20 <div class="contextual">
21 <%= link_to_if_authorized l(:button_edit), {:action => 'edit', :id => message}, :class => 'icon icon-edit' %>
22 <%= link_to_if_authorized l(:button_delete), {:action => 'destroy', :id => message}, :method => :post, :confirm => l(:text_are_you_sure), :class => 'icon icon-del' %>
23 </div>
24 <div class="message reply">
16 25 <h4><%=h message.subject %> - <%= authoring message.created_on, message.author %></h4>
17 26 <div class="wiki"><%= textilizable message.content %></div>
18 27 <%= link_to_attachments message.attachments, :no_author => true %>
28 </div>
19 29 <% end %>
20 </div>
21 30
22 <% if authorize_for('messages', 'reply') %>
31 <% if !@topic.locked? && authorize_for('messages', 'reply') %>
23 32 <p><%= toggle_link l(:button_reply), "reply", :focus => 'message_content' %></p>
24 33 <div id="reply" style="display:none;">
25 <% form_for :reply, @reply, :url => {:action => 'reply', :id => @message}, :html => {:multipart => true} do |f| %>
34 <% form_for :reply, @reply, :url => {:action => 'reply', :id => @topic}, :html => {:multipart => true} do |f| %>
26 35 <%= render :partial => 'form', :locals => {:f => f} %>
27 36 <%= submit_tag l(:button_submit) %>
28 37 <% end %>
29 38 </div>
30 39 <% end %>
@@ -1,103 +1,105
1 1 require 'redmine/access_control'
2 2 require 'redmine/menu_manager'
3 3 require 'redmine/mime_type'
4 4 require 'redmine/themes'
5 5 require 'redmine/plugin'
6 6
7 7 begin
8 8 require_library_or_gem 'RMagick' unless Object.const_defined?(:Magick)
9 9 rescue LoadError
10 10 # RMagick is not available
11 11 end
12 12
13 13 REDMINE_SUPPORTED_SCM = %w( Subversion Darcs Mercurial Cvs )
14 14
15 15 # Permissions
16 16 Redmine::AccessControl.map do |map|
17 17 map.permission :view_project, {:projects => [:show, :activity]}, :public => true
18 18 map.permission :search_project, {:search => :index}, :public => true
19 19 map.permission :edit_project, {:projects => [:settings, :edit]}, :require => :member
20 20 map.permission :select_project_modules, {:projects => :modules}, :require => :member
21 21 map.permission :manage_members, {:projects => :settings, :members => [:new, :edit, :destroy]}, :require => :member
22 22 map.permission :manage_versions, {:projects => [:settings, :add_version], :versions => [:edit, :destroy]}, :require => :member
23 23
24 24 map.project_module :issue_tracking do |map|
25 25 # Issue categories
26 26 map.permission :manage_categories, {:projects => [:settings, :add_issue_category], :issue_categories => [:edit, :destroy]}, :require => :member
27 27 # Issues
28 28 map.permission :view_issues, {:projects => [:changelog, :roadmap],
29 29 :issues => [:index, :changes, :show, :context_menu],
30 30 :queries => :index,
31 31 :reports => :issue_report}, :public => true
32 32 map.permission :add_issues, {:projects => :add_issue}
33 33 map.permission :edit_issues, {:projects => :bulk_edit_issues,
34 34 :issues => [:edit, :destroy_attachment]}
35 35 map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy]}
36 36 map.permission :add_issue_notes, {:issues => :add_note}
37 37 map.permission :change_issue_status, {:issues => :change_status}, :require => :loggedin
38 38 map.permission :move_issues, {:projects => :move_issues}, :require => :loggedin
39 39 map.permission :delete_issues, {:issues => :destroy}, :require => :member
40 40 # Queries
41 41 map.permission :manage_public_queries, {:queries => [:new, :edit, :destroy]}, :require => :member
42 42 map.permission :save_queries, {:queries => [:new, :edit, :destroy]}, :require => :loggedin
43 43 # Gantt & calendar
44 44 map.permission :view_gantt, :projects => :gantt
45 45 map.permission :view_calendar, :projects => :calendar
46 46 end
47 47
48 48 map.project_module :time_tracking do |map|
49 49 map.permission :log_time, {:timelog => :edit}, :require => :loggedin
50 50 map.permission :view_time_entries, :timelog => [:details, :report]
51 51 end
52 52
53 53 map.project_module :news do |map|
54 54 map.permission :manage_news, {:projects => :add_news, :news => [:edit, :destroy, :destroy_comment]}, :require => :member
55 55 map.permission :view_news, {:news => [:index, :show]}, :public => true
56 56 map.permission :comment_news, {:news => :add_comment}
57 57 end
58 58
59 59 map.project_module :documents do |map|
60 60 map.permission :manage_documents, {:projects => :add_document, :documents => [:edit, :destroy, :add_attachment, :destroy_attachment]}, :require => :loggedin
61 61 map.permission :view_documents, :projects => :list_documents, :documents => [:show, :download]
62 62 end
63 63
64 64 map.project_module :files do |map|
65 65 map.permission :manage_files, {:projects => :add_file, :versions => :destroy_file}, :require => :loggedin
66 66 map.permission :view_files, :projects => :list_files, :versions => :download
67 67 end
68 68
69 69 map.project_module :wiki do |map|
70 70 map.permission :manage_wiki, {:wikis => [:edit, :destroy]}, :require => :member
71 71 map.permission :rename_wiki_pages, {:wiki => :rename}, :require => :member
72 72 map.permission :delete_wiki_pages, {:wiki => :destroy}, :require => :member
73 73 map.permission :view_wiki_pages, :wiki => [:index, :history, :diff, :special]
74 74 map.permission :edit_wiki_pages, :wiki => [:edit, :preview, :add_attachment, :destroy_attachment]
75 75 end
76 76
77 77 map.project_module :repository do |map|
78 78 map.permission :manage_repository, {:repositories => [:edit, :destroy]}, :require => :member
79 79 map.permission :browse_repository, :repositories => [:show, :browse, :entry, :changes, :diff, :stats, :graph]
80 80 map.permission :view_changesets, :repositories => [:show, :revisions, :revision]
81 81 end
82 82
83 83 map.project_module :boards do |map|
84 84 map.permission :manage_boards, {:boards => [:new, :edit, :destroy]}, :require => :member
85 85 map.permission :view_messages, {:boards => [:index, :show], :messages => [:show]}, :public => true
86 86 map.permission :add_messages, {:messages => [:new, :reply]}
87 map.permission :edit_messages, {:messages => :edit}, :require => :member
88 map.permission :delete_messages, {:messages => :destroy}, :require => :member
87 89 end
88 90 end
89 91
90 92 # Project menu configuration
91 93 Redmine::MenuManager.map :project_menu do |menu|
92 94 menu.push :label_overview, :controller => 'projects', :action => 'show'
93 95 menu.push :label_activity, :controller => 'projects', :action => 'activity'
94 96 menu.push :label_roadmap, :controller => 'projects', :action => 'roadmap'
95 97 menu.push :label_issue_plural, { :controller => 'issues', :action => 'index' }, :param => :project_id
96 98 menu.push :label_news_plural, { :controller => 'news', :action => 'index' }, :param => :project_id
97 99 menu.push :label_document_plural, :controller => 'projects', :action => 'list_documents'
98 100 menu.push :label_wiki, { :controller => 'wiki', :action => 'index', :page => nil }, :if => Proc.new { |p| p.wiki && !p.wiki.new_record? }
99 101 menu.push :label_board_plural, { :controller => 'boards', :action => 'index', :id => nil }, :param => :project_id, :if => Proc.new { |p| p.boards.any? }
100 102 menu.push :label_attachment_plural, :controller => 'projects', :action => 'list_files'
101 103 menu.push :label_repository, { :controller => 'repositories', :action => 'show' }, :if => Proc.new { |p| p.repository && !p.repository.new_record? }
102 104 menu.push :label_settings, :controller => 'projects', :action => 'settings'
103 105 end
@@ -1,482 +1,487
1 1 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
2 2
3 3 h1, h2, h3, h4 { font-family: "Trebuchet MS", Verdana, sans-serif;}
4 4 h1 {margin:0; padding:0; font-size: 24px;}
5 5 h2, .wiki h1 {font-size: 20px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
6 6 h3, .wiki h2 {font-size: 16px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
7 7 h4, .wiki h3 {font-size: 12px;padding: 2px 10px 1px 0px;margin-bottom: 5px; border-bottom: 1px dotted #bbbbbb; color: #444;}
8 8
9 9 /***** Layout *****/
10 10 #top-menu {background: #2C4056;color: #fff;height:1.5em; padding: 2px 6px 0px 6px;}
11 11 #top-menu a {color: #fff; padding-right: 4px;}
12 12 #account {float:right;}
13 13
14 14 #header {height:5.3em;margin:0;background-color:#507AAA;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
15 15 #header a {color:#f8f8f8;}
16 16 #quick-search {float:right;}
17 17
18 18 #main-menu {position: absolute; bottom: 0px; left:6px;}
19 19 #main-menu ul {margin: 0; padding: 0;}
20 20 #main-menu li {
21 21 float:left;
22 22 list-style-type:none;
23 23 margin: 0px 10px 0px 0px;
24 24 padding: 0px 0px 0px 0px;
25 25 white-space:nowrap;
26 26 }
27 27 #main-menu li a {
28 28 display: block;
29 29 color: #fff;
30 30 text-decoration: none;
31 31 margin: 0;
32 32 padding: 4px 4px 4px 4px;
33 33 background: #2C4056;
34 34 }
35 35 #main-menu li a:hover {background:#759FCF;}
36 36
37 37 #main {background: url(../images/mainbg.png) repeat-x; background-color:#EEEEEE;}
38 38
39 39 #sidebar{ float: right; width: 17%; position: relative; z-index: 9; min-height: 600px; padding: 0; margin: 0;}
40 40 * html #sidebar{ width: 17%; }
41 41 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
42 42 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
43 43 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
44 44
45 45 #content { width: 80%; background: url(../images/contentbg.png) repeat-x; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; height:600px; min-height: 600px;}
46 46 * html #content{ width: 80%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
47 47 html>body #content {
48 48 height: auto;
49 49 min-height: 600px;
50 50 }
51 51
52 52 #main.nosidebar #sidebar{ display: none; }
53 53 #main.nosidebar #content{ width: auto; border-right: 0; }
54 54
55 55 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
56 56
57 57 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
58 58 #login-form table td {padding: 6px;}
59 59 #login-form label {font-weight: bold;}
60 60
61 61 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
62 62
63 63 /***** Links *****/
64 64 a, a:link, a:visited{ color: #2A5685; text-decoration: none; }
65 65 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
66 66 a img{ border: 0; }
67 67
68 68 /***** Tables *****/
69 69 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
70 70 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
71 71 table.list td { overflow: hidden; text-overflow: ellipsis; vertical-align: top;}
72 72 table.list td.id { width: 2%; text-align: center;}
73 73 table.list td.checkbox { width: 15px; padding: 0px;}
74 74
75 75 tr.issue { text-align: center; white-space: nowrap; }
76 76 tr.issue td.subject, tr.issue td.category { white-space: normal; }
77 77 tr.issue td.subject { text-align: left; }
78 78 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
79 79
80 tr.message { height: 2.6em; }
81 tr.message td.last_message { font-size: 80%; }
82 tr.message.locked td.subject a { background-image: url(../images/locked.png); }
83 tr.message.sticky td.subject a { background-image: url(../images/sticky.png); font-weight: bold; }
84
80 85 table.list tbody tr:hover { background-color:#ffffdd; }
81 86 table td {padding:2px;}
82 87 table p {margin:0;}
83 88 .odd {background-color:#f6f7f8;}
84 89 .even {background-color: #fff;}
85 90
86 91 .highlight { background-color: #FCFD8D;}
87 92 .highlight.token-1 { background-color: #faa;}
88 93 .highlight.token-2 { background-color: #afa;}
89 94 .highlight.token-3 { background-color: #aaf;}
90 95
91 96 .box{
92 97 padding:6px;
93 98 margin-bottom: 10px;
94 99 background-color:#f6f6f6;
95 100 color:#505050;
96 101 line-height:1.5em;
97 102 border: 1px solid #e4e4e4;
98 103 }
99 104
100 105 div.square {
101 106 border: 1px solid #999;
102 107 float: left;
103 108 margin: .3em .4em 0 .4em;
104 109 overflow: hidden;
105 110 width: .6em; height: .6em;
106 111 }
107 112
108 113 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px;font-size:0.9em;}
109 114 .splitcontentleft{float:left; width:49%;}
110 115 .splitcontentright{float:right; width:49%;}
111 116 form {display: inline;}
112 117 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
113 118 fieldset {border: 1px solid #e4e4e4; margin:0;}
114 119 legend {color: #484848;}
115 120 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
116 121 textarea.wiki-edit { width: 99%; }
117 122 li p {margin-top: 0;}
118 123 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
119 124 .autoscroll {overflow-x: auto; padding:1px; width:100%;}
120 125 #user_firstname, #user_lastname, #user_mail, #notification_option { width: 90%; }
121 126
122 127 /***** Tabular forms ******/
123 128 .tabular p{
124 129 margin: 0;
125 130 padding: 5px 0 8px 0;
126 131 padding-left: 180px; /*width of left column containing the label elements*/
127 132 height: 1%;
128 133 clear:left;
129 134 }
130 135
131 136 .tabular label{
132 137 font-weight: bold;
133 138 float: left;
134 139 text-align: right;
135 140 margin-left: -180px; /*width of left column*/
136 141 width: 175px; /*width of labels. Should be smaller than left column to create some right
137 142 margin*/
138 143 }
139 144
140 145 .tabular label.floating{
141 146 font-weight: normal;
142 147 margin-left: 0px;
143 148 text-align: left;
144 149 width: 200px;
145 150 }
146 151
147 152 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
148 153
149 154 #settings .tabular p{ padding-left: 300px; }
150 155 #settings .tabular label{ margin-left: -300px; width: 295px; }
151 156
152 157 .required {color: #bb0000;}
153 158 .summary {font-style: italic;}
154 159
155 160 div.attachments p { margin:4px 0 2px 0; }
156 161
157 162 /***** Flash & error messages ****/
158 163 #errorExplanation, div.flash, .nodata {
159 164 padding: 4px 4px 4px 30px;
160 165 margin-bottom: 12px;
161 166 font-size: 1.1em;
162 167 border: 2px solid;
163 168 }
164 169
165 170 div.flash {margin-top: 8px;}
166 171
167 172 div.flash.error, #errorExplanation {
168 173 background: url(../images/false.png) 8px 5px no-repeat;
169 174 background-color: #ffe3e3;
170 175 border-color: #dd0000;
171 176 color: #550000;
172 177 }
173 178
174 179 div.flash.notice {
175 180 background: url(../images/true.png) 8px 5px no-repeat;
176 181 background-color: #dfffdf;
177 182 border-color: #9fcf9f;
178 183 color: #005f00;
179 184 }
180 185
181 186 .nodata {
182 187 text-align: center;
183 188 background-color: #FFEBC1;
184 189 border-color: #FDBF3B;
185 190 color: #A6750C;
186 191 }
187 192
188 193 #errorExplanation ul { font-size: 0.9em;}
189 194
190 195 /***** Ajax indicator ******/
191 196 #ajax-indicator {
192 197 position: absolute; /* fixed not supported by IE */
193 198 background-color:#eee;
194 199 border: 1px solid #bbb;
195 200 top:35%;
196 201 left:40%;
197 202 width:20%;
198 203 font-weight:bold;
199 204 text-align:center;
200 205 padding:0.6em;
201 206 z-index:100;
202 207 filter:alpha(opacity=50);
203 208 -moz-opacity:0.5;
204 209 opacity: 0.5;
205 210 -khtml-opacity: 0.5;
206 211 }
207 212
208 213 html>body #ajax-indicator { position: fixed; }
209 214
210 215 #ajax-indicator span {
211 216 background-position: 0% 40%;
212 217 background-repeat: no-repeat;
213 218 background-image: url(../images/loading.gif);
214 219 padding-left: 26px;
215 220 vertical-align: bottom;
216 221 }
217 222
218 223 /***** Calendar *****/
219 224 table.cal {border-collapse: collapse; width: 100%; margin: 8px 0 6px 0;border: 1px solid #d7d7d7;}
220 225 table.cal thead th {width: 14%;}
221 226 table.cal tbody tr {height: 100px;}
222 227 table.cal th { background-color:#EEEEEE; padding: 4px; }
223 228 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
224 229 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
225 230 table.cal td.odd p.day-num {color: #bbb;}
226 231 table.cal td.today {background:#ffffdd;}
227 232 table.cal td.today p.day-num {font-weight: bold;}
228 233
229 234 /***** Tooltips ******/
230 235 .tooltip{position:relative;z-index:24;}
231 236 .tooltip:hover{z-index:25;color:#000;}
232 237 .tooltip span.tip{display: none; text-align:left;}
233 238
234 239 div.tooltip:hover span.tip{
235 240 display:block;
236 241 position:absolute;
237 242 top:12px; left:24px; width:270px;
238 243 border:1px solid #555;
239 244 background-color:#fff;
240 245 padding: 4px;
241 246 font-size: 0.8em;
242 247 color:#505050;
243 248 }
244 249
245 250 /***** Progress bar *****/
246 251 table.progress {
247 252 border: 1px solid #D7D7D7;
248 253 border-collapse: collapse;
249 254 border-spacing: 0pt;
250 255 empty-cells: show;
251 256 text-align: center;
252 257 float:left;
253 258 margin: 1px 6px 1px 0px;
254 259 }
255 260
256 261 table.progress td { height: 0.9em; }
257 262 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
258 263 table.progress td.open { background: #FFF none repeat scroll 0%; }
259 264 p.pourcent {font-size: 80%;}
260 265 p.progress-info {clear: left; font-style: italic; font-size: 80%;}
261 266
262 267 /***** Tabs *****/
263 268 #content .tabs{height: 2.6em;}
264 269 #content .tabs ul{margin:0;}
265 270 #content .tabs ul li{
266 271 float:left;
267 272 list-style-type:none;
268 273 white-space:nowrap;
269 274 margin-right:8px;
270 275 background:#fff;
271 276 }
272 277 #content .tabs ul li a{
273 278 display:block;
274 279 font-size: 0.9em;
275 280 text-decoration:none;
276 281 line-height:1em;
277 282 padding:4px;
278 283 border: 1px solid #c0c0c0;
279 284 }
280 285
281 286 #content .tabs ul li a.selected, #content .tabs ul li a:hover{
282 287 background-color: #507AAA;
283 288 border: 1px solid #507AAA;
284 289 color: #fff;
285 290 text-decoration:none;
286 291 }
287 292
288 293 /***** Diff *****/
289 294 .diff_out { background: #fcc; }
290 295 .diff_in { background: #cfc; }
291 296
292 297 /***** Wiki *****/
293 298 div.wiki table {
294 299 border: 1px solid #505050;
295 300 border-collapse: collapse;
296 301 }
297 302
298 303 div.wiki table, div.wiki td, div.wiki th {
299 304 border: 1px solid #bbb;
300 305 padding: 4px;
301 306 }
302 307
303 308 div.wiki .external {
304 309 background-position: 0% 60%;
305 310 background-repeat: no-repeat;
306 311 padding-left: 12px;
307 312 background-image: url(../images/external.png);
308 313 }
309 314
310 315 div.wiki a.new {
311 316 color: #b73535;
312 317 }
313 318
314 319 div.wiki pre {
315 320 margin: 1em 1em 1em 1.6em;
316 321 padding: 2px;
317 322 background-color: #fafafa;
318 323 border: 1px solid #dadada;
319 324 width:95%;
320 325 overflow-x: auto;
321 326 }
322 327
323 328 div.wiki div.toc {
324 329 background-color: #ffffdd;
325 330 border: 1px solid #e4e4e4;
326 331 padding: 4px;
327 332 line-height: 1.2em;
328 333 margin-bottom: 12px;
329 334 margin-right: 12px;
330 335 display: table
331 336 }
332 337 * html div.wiki div.toc { width: 50%; } /* IE6 doesn't autosize div */
333 338
334 339 div.wiki div.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
335 340 div.wiki div.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
336 341
337 342 div.wiki div.toc a {
338 343 display: block;
339 344 font-size: 0.9em;
340 345 font-weight: normal;
341 346 text-decoration: none;
342 347 color: #606060;
343 348 }
344 349 div.wiki div.toc a:hover { color: #c61a1a; text-decoration: underline;}
345 350
346 351 div.wiki div.toc a.heading2 { margin-left: 6px; }
347 352 div.wiki div.toc a.heading3 { margin-left: 12px; font-size: 0.8em; }
348 353
349 354 /***** My page layout *****/
350 355 .block-receiver {
351 356 border:1px dashed #c0c0c0;
352 357 margin-bottom: 20px;
353 358 padding: 15px 0 15px 0;
354 359 }
355 360
356 361 .mypage-box {
357 362 margin:0 0 20px 0;
358 363 color:#505050;
359 364 line-height:1.5em;
360 365 }
361 366
362 367 .handle {
363 368 cursor: move;
364 369 }
365 370
366 371 a.close-icon {
367 372 display:block;
368 373 margin-top:3px;
369 374 overflow:hidden;
370 375 width:12px;
371 376 height:12px;
372 377 background-repeat: no-repeat;
373 378 cursor:pointer;
374 379 background-image:url('../images/close.png');
375 380 }
376 381
377 382 a.close-icon:hover {
378 383 background-image:url('../images/close_hl.png');
379 384 }
380 385
381 386 /***** Gantt chart *****/
382 387 .gantt_hdr {
383 388 position:absolute;
384 389 top:0;
385 390 height:16px;
386 391 border-top: 1px solid #c0c0c0;
387 392 border-bottom: 1px solid #c0c0c0;
388 393 border-right: 1px solid #c0c0c0;
389 394 text-align: center;
390 395 overflow: hidden;
391 396 }
392 397
393 398 .task {
394 399 position: absolute;
395 400 height:8px;
396 401 font-size:0.8em;
397 402 color:#888;
398 403 padding:0;
399 404 margin:0;
400 405 line-height:0.8em;
401 406 }
402 407
403 408 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
404 409 .task_done { background:#66f url(../images/task_done.png); border: 1px solid #66f; }
405 410 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
406 411 .milestone { background-image:url(../images/milestone.png); background-repeat: no-repeat; border: 0; }
407 412
408 413 /***** Icons *****/
409 414 .icon {
410 415 background-position: 0% 40%;
411 416 background-repeat: no-repeat;
412 417 padding-left: 20px;
413 418 padding-top: 2px;
414 419 padding-bottom: 3px;
415 420 }
416 421
417 422 .icon22 {
418 423 background-position: 0% 40%;
419 424 background-repeat: no-repeat;
420 425 padding-left: 26px;
421 426 line-height: 22px;
422 427 vertical-align: middle;
423 428 }
424 429
425 430 .icon-add { background-image: url(../images/add.png); }
426 431 .icon-edit { background-image: url(../images/edit.png); }
427 432 .icon-copy { background-image: url(../images/copy.png); }
428 433 .icon-del { background-image: url(../images/delete.png); }
429 434 .icon-move { background-image: url(../images/move.png); }
430 435 .icon-save { background-image: url(../images/save.png); }
431 436 .icon-cancel { background-image: url(../images/cancel.png); }
432 437 .icon-pdf { background-image: url(../images/pdf.png); }
433 438 .icon-csv { background-image: url(../images/csv.png); }
434 439 .icon-html { background-image: url(../images/html.png); }
435 440 .icon-image { background-image: url(../images/image.png); }
436 441 .icon-txt { background-image: url(../images/txt.png); }
437 442 .icon-file { background-image: url(../images/file.png); }
438 443 .icon-folder { background-image: url(../images/folder.png); }
439 444 .open .icon-folder { background-image: url(../images/folder_open.png); }
440 445 .icon-package { background-image: url(../images/package.png); }
441 446 .icon-home { background-image: url(../images/home.png); }
442 447 .icon-user { background-image: url(../images/user.png); }
443 448 .icon-mypage { background-image: url(../images/user_page.png); }
444 449 .icon-admin { background-image: url(../images/admin.png); }
445 450 .icon-projects { background-image: url(../images/projects.png); }
446 451 .icon-logout { background-image: url(../images/logout.png); }
447 452 .icon-help { background-image: url(../images/help.png); }
448 453 .icon-attachment { background-image: url(../images/attachment.png); }
449 454 .icon-index { background-image: url(../images/index.png); }
450 455 .icon-history { background-image: url(../images/history.png); }
451 456 .icon-feed { background-image: url(../images/feed.png); }
452 457 .icon-time { background-image: url(../images/time.png); }
453 458 .icon-stats { background-image: url(../images/stats.png); }
454 459 .icon-warning { background-image: url(../images/warning.png); }
455 460 .icon-fav { background-image: url(../images/fav.png); }
456 461 .icon-fav-off { background-image: url(../images/fav_off.png); }
457 462 .icon-reload { background-image: url(../images/reload.png); }
458 463 .icon-lock { background-image: url(../images/locked.png); }
459 464 .icon-unlock { background-image: url(../images/unlock.png); }
460 465 .icon-note { background-image: url(../images/note.png); }
461 466 .icon-checked { background-image: url(../images/true.png); }
462 467
463 468 .icon22-projects { background-image: url(../images/22x22/projects.png); }
464 469 .icon22-users { background-image: url(../images/22x22/users.png); }
465 470 .icon22-tracker { background-image: url(../images/22x22/tracker.png); }
466 471 .icon22-role { background-image: url(../images/22x22/role.png); }
467 472 .icon22-workflow { background-image: url(../images/22x22/workflow.png); }
468 473 .icon22-options { background-image: url(../images/22x22/options.png); }
469 474 .icon22-notifications { background-image: url(../images/22x22/notifications.png); }
470 475 .icon22-authent { background-image: url(../images/22x22/authent.png); }
471 476 .icon22-info { background-image: url(../images/22x22/info.png); }
472 477 .icon22-comment { background-image: url(../images/22x22/comment.png); }
473 478 .icon22-package { background-image: url(../images/22x22/package.png); }
474 479 .icon22-settings { background-image: url(../images/22x22/settings.png); }
475 480 .icon22-plugin { background-image: url(../images/22x22/plugin.png); }
476 481
477 482 /***** Media print specific styles *****/
478 483 @media print {
479 484 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual { display:none; }
480 485 #main { background: #fff; }
481 486 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; }
482 487 }
@@ -1,19 +1,19
1 1 ---
2 2 boards_001:
3 3 name: Help
4 4 project_id: 1
5 topics_count: 1
5 topics_count: 2
6 6 id: 1
7 7 description: Help board
8 8 position: 1
9 last_message_id: 2
10 messages_count: 2
9 last_message_id: 5
10 messages_count: 5
11 11 boards_002:
12 12 name: Discussion
13 13 project_id: 1
14 14 topics_count: 0
15 15 id: 2
16 16 description: Discussion board
17 17 position: 2
18 18 last_message_id:
19 19 messages_count: 0
@@ -1,25 +1,57
1 1 ---
2 2 messages_001:
3 3 created_on: 2007-05-12 17:15:32 +02:00
4 4 updated_on: 2007-05-12 17:15:32 +02:00
5 5 subject: First post
6 6 id: 1
7 replies_count: 1
8 last_reply_id: 2
7 replies_count: 2
8 last_reply_id: 3
9 9 content: "This is the very first post\n\
10 10 in the forum"
11 11 author_id: 1
12 12 parent_id:
13 13 board_id: 1
14 14 messages_002:
15 15 created_on: 2007-05-12 17:18:00 +02:00
16 16 updated_on: 2007-05-12 17:18:00 +02:00
17 17 subject: First reply
18 18 id: 2
19 19 replies_count: 0
20 20 last_reply_id:
21 21 content: "Reply to the first post"
22 22 author_id: 1
23 23 parent_id: 1
24 24 board_id: 1
25 No newline at end of file
25 messages_003:
26 created_on: 2007-05-12 17:18:02 +02:00
27 updated_on: 2007-05-12 17:18:02 +02:00
28 subject: "RE: First post"
29 id: 3
30 replies_count: 0
31 last_reply_id:
32 content: "An other reply"
33 author_id:
34 parent_id: 1
35 board_id: 1
36 messages_004:
37 created_on: 2007-08-12 17:15:32 +02:00
38 updated_on: 2007-08-12 17:15:32 +02:00
39 subject: Post 2
40 id: 4
41 replies_count: 1
42 last_reply_id: 5
43 content: "This is an other post"
44 author_id:
45 parent_id:
46 board_id: 1
47 messages_005:
48 created_on: 2007-09-12 17:18:00 +02:00
49 updated_on: 2007-09-12 17:18:00 +02:00
50 subject: 'RE: post 2'
51 id: 5
52 replies_count: 0
53 last_reply_id:
54 content: "Reply to the second post"
55 author_id: 1
56 parent_id: 4
57 board_id: 1
@@ -1,44 +1,70
1 1 require File.dirname(__FILE__) + '/../test_helper'
2 2
3 3 class MessageTest < Test::Unit::TestCase
4 4 fixtures :projects, :boards, :messages
5 5
6 6 def setup
7 7 @board = Board.find(1)
8 8 @user = User.find(1)
9 9 end
10 10
11 11 def test_create
12 12 topics_count = @board.topics_count
13 13 messages_count = @board.messages_count
14 14
15 15 message = Message.new(:board => @board, :subject => 'Test message', :content => 'Test message content', :author => @user)
16 16 assert message.save
17 17 @board.reload
18 18 # topics count incremented
19 19 assert_equal topics_count+1, @board[:topics_count]
20 20 # messages count incremented
21 21 assert_equal messages_count+1, @board[:messages_count]
22 22 assert_equal message, @board.last_message
23 23 end
24 24
25 25 def test_reply
26 26 topics_count = @board.topics_count
27 27 messages_count = @board.messages_count
28 28 @message = Message.find(1)
29 29 replies_count = @message.replies_count
30 30
31 31 reply = Message.new(:board => @board, :subject => 'Test reply', :content => 'Test reply content', :parent => @message, :author => @user)
32 32 assert reply.save
33 33 @board.reload
34 34 # same topics count
35 35 assert_equal topics_count, @board[:topics_count]
36 36 # messages count incremented
37 37 assert_equal messages_count+1, @board[:messages_count]
38 38 assert_equal reply, @board.last_message
39 39 @message.reload
40 40 # replies count incremented
41 41 assert_equal replies_count+1, @message[:replies_count]
42 42 assert_equal reply, @message.last_reply
43 43 end
44
45 def test_destroy_topic
46 message = Message.find(1)
47 board = message.board
48 topics_count, messages_count = board.topics_count, board.messages_count
49 assert message.destroy
50 board.reload
51
52 # Replies deleted
53 assert Message.find_all_by_parent_id(1).empty?
54 # Checks counters
55 assert_equal topics_count - 1, board.topics_count
56 assert_equal messages_count - 3, board.messages_count
57 end
58
59 def test_destroy_reply
60 message = Message.find(5)
61 board = message.board
62 topics_count, messages_count = board.topics_count, board.messages_count
63 assert message.destroy
64 board.reload
65
66 # Checks counters
67 assert_equal topics_count, board.topics_count
68 assert_equal messages_count - 1, board.messages_count
69 end
44 70 end
General Comments 0
You need to be logged in to leave comments. Login now