]> source.dussan.org Git - redmine.git/commitdiff
Option to switch between table list and board list (#29482).
authorJean-Philippe Lang <jp_lang@yahoo.fr>
Sat, 19 Oct 2019 11:42:20 +0000 (11:42 +0000)
committerJean-Philippe Lang <jp_lang@yahoo.fr>
Sat, 19 Oct 2019 11:42:20 +0000 (11:42 +0000)
Patch by Marius BALTEANU.

git-svn-id: http://svn.redmine.org/redmine/trunk@18765 e93f8b46-1217-0410-a6f0-8f06a7374b81

15 files changed:
app/controllers/projects_controller.rb
app/helpers/projects_helper.rb
app/helpers/projects_queries_helper.rb [new file with mode: 0644]
app/helpers/queries_helper.rb
app/models/project_query.rb
app/models/query.rb
app/views/projects/_board.html.erb [new file with mode: 0644]
app/views/projects/_list.html.erb [new file with mode: 0644]
app/views/projects/index.html.erb
app/views/queries/_form.html.erb
app/views/queries/_query_form.html.erb
config/locales/en.yml
public/stylesheets/application.css
test/functional/projects_controller_test.rb
test/unit/project_query_test.rb

index e7a79e4954dff0ebe073628ad107e358dbd08a2a..b9bda6b43f80193291dd78caad0600b03c7ef070 100644 (file)
@@ -34,6 +34,8 @@ class ProjectsController < ApplicationController
   helper :issues
   helper :queries
   include QueriesHelper
+  helper :projects_queries
+  include ProjectsQueriesHelper
   helper :repositories
   helper :members
   helper :trackers
@@ -50,7 +52,9 @@ class ProjectsController < ApplicationController
 
     respond_to do |format|
       format.html {
-        @projects = scope.to_a
+        @entry_count = scope.count
+        @entry_pages = Paginator.new @entry_count, per_page_option, params['page']
+        @entries = scope.offset(@entry_pages.offset).limit(@entry_pages.per_page).to_a
       }
       format.api  {
         @offset, @limit = api_offset_and_limit
@@ -61,6 +65,11 @@ class ProjectsController < ApplicationController
         projects = scope.reorder(:created_on => :desc).limit(Setting.feeds_limit.to_i).to_a
         render_feed(projects, :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
       }
+      format.csv {
+        # Export all entries
+        @entries = scope.to_a
+        send_data(query_to_csv(@entries, @query, params), :type => 'text/csv; header=present', :filename => 'projects.csv')
+      }
     end
   end
 
index bdb1ac44cb6f20a7473d2273018213b3941bcd2a..335db8b0b8c9f0faad6cc355a17659e809d6568a 100644 (file)
@@ -158,4 +158,13 @@ module ProjectsHelper
     url = bookmark_project_url(project)
     link_to text, url, remote: true, method: method, class: css
   end
+
+  def grouped_project_list(projects, query, &block)
+    ancestors = []
+    grouped_query_results(projects, query) do |project, group_name, group_count, group_totals|
+      ancestors.pop while ancestors.any? && !project.is_descendant_of?(ancestors.last)
+      yield project, ancestors.size, group_name, group_count, group_totals
+      ancestors << project unless project.leaf?
+    end
+  end
 end
diff --git a/app/helpers/projects_queries_helper.rb b/app/helpers/projects_queries_helper.rb
new file mode 100644 (file)
index 0000000..b5de291
--- /dev/null
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-2017  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+module ProjectsQueriesHelper
+  include ApplicationHelper
+
+  def column_value(column, item, value)
+    if item.is_a?(Project)
+      case column.name
+      when :name
+        link_to_project(item) + (content_tag('span', '', :class => 'icon icon-user my-project', :title => l(:label_my_projects)) if User.current.member_of?(item))
+      when :short_description
+        item.description? ? content_tag('div', textilizable(item, :short_description), :class => "wiki") : ''
+      when :homepage
+        item.homepage? ? content_tag('div', textilizable(item, :homepage), :class => "wiki") : ''
+      when :status
+        get_project_status_label[column.value_object(item)]
+      when :parent_id
+        link_to_project(item.parent) unless item.parent.nil?
+      else
+        super
+      end
+    end
+  end
+
+  def csv_content(column, item)
+    if item.is_a?(Project)
+      case column.name
+      when :status
+        get_project_status_label[column.value_object(item)]
+      when :parent_id
+        return item.parent.name unless item.parent.nil?
+      end
+    end
+    super
+  end
+
+  private
+
+  def get_project_status_label
+    {
+      Project::STATUS_ACTIVE => l(:project_status_active),
+      Project::STATUS_CLOSED => l(:project_status_closed)
+    }
+  end
+end
index fa03a76ef11dc63a80b5dc8cf5aff743d6dab59c..9ed2dd21d0142e3ab3924848d53f97b04c38bd90 100644 (file)
@@ -120,6 +120,14 @@ module QueriesHelper
     render :partial => 'queries/columns', :locals => {:query => query, :tag_name => tag_name}
   end
 
+  def available_display_types_tags(query)
+    available_display_types = []
+    query.available_display_types.each do |t|
+      available_display_types << [l(:"label_display_type_#{t}"), t]
+    end
+    select_tag('display_type', options_for_select(available_display_types, @query.display_type), :id => 'display_type')
+  end
+
   def grouped_query_results(items, query, &block)
     result_count_by_group = query.result_count_by_group
     previous_group, first = false, true
index 2bc6b40e1d49a046e9ad05776935c42132cd545e..ecf3e687b4a768f274002a09b53304687e98b334 100644 (file)
@@ -22,7 +22,16 @@ class ProjectQuery < Query
   self.queried_class = Project
   self.view_permission = :search_project
 
-  self.available_columns = []
+  self.available_columns = [
+    QueryColumn.new(:name, :sortable => "#{Project.table_name}.name"),
+    QueryColumn.new(:status, :sortable => "#{Project.table_name}.status"),
+    QueryColumn.new(:short_description, :sortable => "#{Project.table_name}.description", :caption => :field_description),
+    QueryColumn.new(:homepage, :sortable => "#{Project.table_name}.homepage"),
+    QueryColumn.new(:identifier, :sortable => "#{Project.table_name}.identifier"),
+    QueryColumn.new(:parent_id, :sortable => "#{Project.table_name}.lft ASC", :default_order => 'desc', :caption => :field_parent),
+    QueryColumn.new(:is_public, :sortable => "#{Project.table_name}.is_public", :groupable => true),
+    QueryColumn.new(:created_on, :sortable => "#{Project.table_name}.created_on", :default_order => 'desc')
+  ]
 
   def initialize(attributes=nil, *args)
     super attributes
@@ -48,7 +57,23 @@ class ProjectQuery < Query
   end
 
   def available_columns
-    []
+    return @available_columns if @available_columns
+    @available_columns = self.class.available_columns.dup
+    @available_columns += ProjectCustomField.visible.
+                            map {|cf| QueryAssociationCustomFieldColumn.new(:project, cf) }
+    @available_columns
+  end
+
+  def available_display_types
+    ['board', 'list']
+  end
+
+  def default_columns_names
+    @default_columns_names ||= [:name, :identifier, :short_description]
+  end
+
+  def default_sort_criteria
+    [[]]
   end
 
   def base_scope
index 7372f712e0d653cbc1a26b91f37d05b00da54227..19db899d8a47a1dee04130e8c18ab7b130731544 100644 (file)
@@ -409,6 +409,7 @@ class Query < ActiveRecord::Base
     self.column_names = params[:c] || query_params[:column_names] || self.column_names
     self.totalable_names = params[:t] || query_params[:totalable_names] || self.totalable_names
     self.sort_criteria = params[:sort] || query_params[:sort_criteria] || self.sort_criteria
+    self.display_type = params[:display_type] || query_params[:display_type] || self.display_type
     self
   end
 
@@ -983,6 +984,21 @@ class Query < ActiveRecord::Base
     end
   end
 
+  def display_type
+    options[:display_type] || self.available_display_types.first
+  end
+
+  def display_type=(type)
+    unless type || self.available_display_types.include?(type)
+      type = self.available_display_types.first
+    end
+    options[:display_type] = type
+  end
+
+  def available_display_types
+    ['list']
+  end
+
   private
 
   def grouped_query(&block)
diff --git a/app/views/projects/_board.html.erb b/app/views/projects/_board.html.erb
new file mode 100644 (file)
index 0000000..7d63a53
--- /dev/null
@@ -0,0 +1,3 @@
+<div id="projects-index">
+  <%= render_project_hierarchy(@entries) %>
+</div>
diff --git a/app/views/projects/_list.html.erb b/app/views/projects/_list.html.erb
new file mode 100644 (file)
index 0000000..8a1efc2
--- /dev/null
@@ -0,0 +1,35 @@
+<div class="autoscroll">
+<table class="list projects odd-even <%= @query.css_classes %>">
+<thead>
+  <tr>
+    <% @query.inline_columns.each do |column| %>
+      <%= column_header(@query, column) %>
+    <% end %>
+  </tr>
+</thead>
+<tbody>
+<% grouped_project_list(entries, @query) do |entry, level, group_name, group_count, group_totals| -%>
+  <% if group_name %>
+    <% reset_cycle %>
+    <tr class="group open">
+      <td colspan="<%= @query.inline_columns.size %>">
+        <span class="expander" onclick="toggleRowGroup(this);">&nbsp;</span>
+        <span class="name"><%= group_name %></span>
+        <% if group_count %>
+        <span class="count"><%= group_count %></span>
+        <% end %>
+        <span class="totals"><%= group_totals %></span>
+        <%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}",
+                             "toggleAllRowGroups(this)", :class => 'toggle-all') %>
+      </td>
+    </tr>
+  <% end %>
+  <tr id="project-<%= entry.id %>" class="<%= cycle('odd', 'even') %> <%= entry.css_classes %> <%= level > 0 ? "idnt idnt-#{level}" : nil %>">
+    <% @query.inline_columns.each do |column| %>
+    <%= content_tag('td', column_content(column, entry), :class => column.css_classes) %>
+    <% end %>
+  </tr>
+<% end -%>
+</tbody>
+</table>
+</div>
index 3c2543b43243511acaffbdfb1e1ddcd1390f2f6a..067c9bdee19fcd2eca54f6668ff84a178719c794 100644 (file)
 <% end %>
 
 <% if @query.valid? %>
-  <% if @projects.empty? %>
+  <% if @entries.empty? %>
     <p class="nodata"><%= l(:label_no_data) %></p>
   <% else %>
-    <div id="projects-index">
-      <%= render_project_hierarchy(@projects) %>
-    </div>
+    <%= render :partial => @query.display_type, :locals => { :entries => @entries }%>
+    <span class="pagination"><%= pagination_links_full @entry_pages, @entry_count %></span>
   <% end %>
 <% end %>
 
index a2dd9f5891c37f498b6207278bcae240d7718f73..12a1d0202b8246fe7def1000c71bc3b8dbf11fb6 100644 (file)
 <% end %>
 
 <fieldset id="options"><legend><%= l(:label_options) %></legend>
-<p><label for="query_default_columns"><%=l(:label_default_columns)%></label>
+  <% if @query.available_display_types.size > 1 %>
+  <p><label for='display_type'><%= l(:label_display_type) %></label>
+    <%= available_display_types_tags(@query) %>
+  </p>
+<% end %>
+
+<p id ="default_columns"><label for="query_default_columns"><%=l(:label_default_columns)%></label>
 <%= check_box_tag 'default_columns', 1, @query.has_default_columns?, :id => 'query_default_columns',
       :data => {:disables => "#columns, .block_columns input"} %></p>
 
 <% unless params[:gantt] %>
-  <p><label for="query_group_by"><%= l(:field_group_by) %></label>
+  <p id="group_by"><label id="group_by" for="query_group_by"><%= l(:field_group_by) %></label>
   <%= select 'query', 'group_by', @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]}, :include_blank => true %></p>
 
   <% unless @query.available_block_columns.empty? %>
@@ -99,4 +105,15 @@ $(document).ready(function(){
     $("input.disable-unless-private").attr('disabled', !private_checked);
   }).trigger('change');
 });
+
+$(function ($) {
+  $('#display_type').change(function (e) {
+    var option = $(e.target).val()
+    if (option == 'board') {
+      $('fieldset#columns, fieldset#sort, p#default_columns, p#group_by').hide();
+    } else {
+      $('fieldset#columns, fieldset#sort, p#default_columns, p#group_by').show();
+    }
+  }).change()
+});
 <% end %>
index 65bcc3eb51c3e98503456fe98a0868c7efe271d8..62d1566844589febaaf34c3cb872d70bf7c80b0f 100644 (file)
   <% if @query.available_columns.any? %>
     <fieldset id="options" class="collapsible collapsed">
       <legend onclick="toggleFieldset(this);" class="icon icon-collapsed"><%= l(:label_options) %></legend>
-      <div style="display: none;">
-        <table>
+        <div class="hidden">
+          <% if @query.available_display_types.size > 1 %>
+          <div>
+            <span class="field"><label for='display_type'><%= l(:label_display_type) %></label></span>
+            <%= available_display_types_tags(@query) %>
+          </div>
+          <% end %>
+          <table id="list" class="<%= 'hidden' if (@query.display_type == 'board') %>">
           <% if @query.available_columns.any? %>
             <tr>
               <td class="field"><%= l(:field_column_names) %></td>
 </div>
 
 <%= error_messages_for @query %>
+
+<%= javascript_tag do %>
+$(function ($) {
+  $('#display_type').change(function (e) {
+    var option = $(e.target).val()
+    if (option == 'board') {
+      $('table#list').hide();
+    } else {
+      $('table#list').show();
+    }
+
+  })
+});
+
+<% end %>
index 4c90deb9dbe9e22e23e92a4873fdf1d12e3fdcb3..e5ae3220f0cf87c14c8d331fa7643b0f4b812325 100644 (file)
@@ -1072,6 +1072,9 @@ en:
   label_password_char_class_lowercase: lowercase letters
   label_password_char_class_digits: digits
   label_password_char_class_special_chars: special characters
+  label_display_type: Display results as
+  label_display_type_list: List
+  label_display_type_board: Board  
 
   button_login: Login
   button_submit: Submit
index 7aa1bca54c45b6a5e97ef4c1d5010e8a3025cbdb..0abd4fdaef4e79478e6331f7dec64b5858c69f24 100644 (file)
@@ -130,6 +130,7 @@ div.modal .box p {margin: 0.3em 0;}
 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
 
 .mobile-show {display: none;}
+.hidden {display: none;}
 
 /***** Links *****/
 a, a:link, a:visited{ color: #169; text-decoration: none; }
@@ -235,7 +236,8 @@ table.list, .table-list { border: 1px solid #e4e4e4; width: 100%; margin-bottom:
 table.list th, .table-list-header { background-color:#EEEEEE; padding: 4px; white-space:nowrap; font-weight:bold; }
 table.list td {text-align:center; vertical-align:middle; padding-right:10px;}
 table.list td.id { width: 2%; text-align: center;}
-table.list td.name, table.list td.description, table.list td.subject, table.list td.comments, table.list td.roles, table.list td.attachments, table.list td.text {text-align: left;}
+table.list td.name, table.list td.description, table.list td.subject, table.list td.comments, table.list td.roles, table.list td.attachments, table.list td.text,  table.list td.short_description {text-align: left;}
+
 table.list td.attachments a {display:block;}
 table.list td.tick {width:15%}
 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
@@ -257,17 +259,6 @@ tr.project td.name a { white-space:nowrap; }
 tr.project.closed, tr.project.archived { color: #aaa; }
 tr.project.closed a, tr.project.archived a { color: #aaa; }
 
-tr.project.idnt td.name span {background: url(../images/arrow_right.png) no-repeat 2px 50%; padding-left: 16px;}
-tr.project.idnt-1 td.name {padding-left: 0.5em;}
-tr.project.idnt-2 td.name {padding-left: 2em;}
-tr.project.idnt-3 td.name {padding-left: 3.5em;}
-tr.project.idnt-4 td.name {padding-left: 5em;}
-tr.project.idnt-5 td.name {padding-left: 6.5em;}
-tr.project.idnt-6 td.name {padding-left: 8em;}
-tr.project.idnt-7 td.name {padding-left: 9.5em;}
-tr.project.idnt-8 td.name {padding-left: 11em;}
-tr.project.idnt-9 td.name {padding-left: 12.5em;}
-
 tr.issue { text-align: center; white-space: nowrap; }
 tr.issue td.subject, tr.issue td.category, td.assigned_to, td.last_updated_by, tr.issue td.string, tr.issue td.text, tr.issue td.list, tr.issue td.relations, tr.issue td.parent { white-space: normal; }
 tr.issue td.relations { text-align: left; }
@@ -277,16 +268,16 @@ table.issues td.block_column {color:#777; font-size:90%; padding:4px 4px 4px 24p
 table.issues td.block_column span {font-weight: bold; display: block; margin-bottom: 4px;}
 table.issues td.block_column pre {white-space:normal;}
 
-tr.issue.idnt td.subject {background: url(../images/arrow_right.png) no-repeat 2px 50%;}
-tr.issue.idnt-1 td.subject {padding-left: 24px; background-position: 8px 50%;}
-tr.issue.idnt-2 td.subject {padding-left: 40px; background-position: 24px 50%;}
-tr.issue.idnt-3 td.subject {padding-left: 56px; background-position: 40px 50%;}
-tr.issue.idnt-4 td.subject {padding-left: 72px; background-position: 56px 50%;}
-tr.issue.idnt-5 td.subject {padding-left: 88px; background-position: 72px 50%;}
-tr.issue.idnt-6 td.subject {padding-left: 104px; background-position: 88px 50%;}
-tr.issue.idnt-7 td.subject {padding-left: 120px; background-position: 104px 50%;}
-tr.issue.idnt-8 td.subject {padding-left: 136px; background-position: 120px 50%;}
-tr.issue.idnt-9 td.subject {padding-left: 152px; background-position: 136px 50%;}
+tr.issue.idnt td.subject, tr.project.idnt td.name {background: url(../images/arrow_right.png) no-repeat 2px 50%;}
+tr.issue.idnt-1 td.subject, tr.project.idnt-1 td.name {padding-left: 24px; background-position: 8px 50%;}
+tr.issue.idnt-2 td.subject, tr.project.idnt-2 td.name {padding-left: 40px; background-position: 24px 50%;}
+tr.issue.idnt-3 td.subject, tr.project.idnt-3 td.name {padding-left: 56px; background-position: 40px 50%;}
+tr.issue.idnt-4 td.subject, tr.project.idnt-4 td.name {padding-left: 72px; background-position: 56px 50%;}
+tr.issue.idnt-5 td.subject, tr.project.idnt-5 td.name {padding-left: 88px; background-position: 72px 50%;}
+tr.issue.idnt-6 td.subject, tr.project.idnt-6 td.name {padding-left: 104px; background-position: 88px 50%;}
+tr.issue.idnt-7 td.subject, tr.project.idnt-7 td.name {padding-left: 120px; background-position: 104px 50%;}
+tr.issue.idnt-8 td.subject, tr.project.idnt-8 td.name {padding-left: 136px; background-position: 120px 50%;}
+tr.issue.idnt-9 td.subject, tr.project.idnt-9 td.name {padding-left: 152px; background-position: 136px 50%;}
 
 table.issue-report {table-layout:fixed;}
 .issue-report-graph {width: 75%; margin: 2em 0;}
index 3f4dd696775e834eb73cf23da7db0a00d7ba0654..5e1efd240289a331734574942a646225309e8ea9 100644 (file)
@@ -94,6 +94,59 @@ class ProjectsControllerTest < Redmine::ControllerTest
     end
   end
 
+  def test_index_as_list_should_format_column_value
+    get :index, :params => {
+      :c => ['name', 'status', 'short_description', 'homepage', 'parent_id', 'identifier', 'is_public', 'created_on', 'project.cf_3'],
+      :display_type => 'list'
+    }
+    assert_response :success
+
+    assert_select 'table.projects' do
+      assert_select 'tr[id=?]', 'project-1' do
+        assert_select 'td.name a[href=?]', '/projects/ecookbook', :text => 'eCookbook'
+        assert_select 'td.status', :text => 'active'
+        assert_select 'td.short_description', :text => 'Recipes management application'
+        assert_select 'td.homepage a.external', :text => 'http://ecookbook.somenet.foo/'
+        assert_select 'td.identifier', :text => 'ecookbook'
+        assert_select 'td.is_public', :text => 'Yes'
+        assert_select 'td.created_on', :text => '07/19/2006 05:13 PM'
+        assert_select 'td.project_cf_3.list', :text => 'Stable'
+      end
+      assert_select 'tr[id=?]', 'project-4' do
+        assert_select 'td.parent_id a[href=?]', '/projects/ecookbook', :text => 'eCookbook'
+      end
+    end
+  end
+
+  def test_index_as_list_should_show_my_favourite_projects
+    @request.session[:user_id] = 1
+    get :index, :params => {
+      :display_type => 'list'
+    }
+
+    assert_response :success
+    assert_select 'tr[id=?] td.name span[class=?]', 'project-5', 'icon icon-user my-project'
+  end
+
+  def test_index_as_list_should_indent_projects
+    @request.session[:user_id] = 1
+    get :index, :params => {
+      :c => ['name', 'short_description'],
+      :sort => 'parent_id:desc,lft:desc',
+      :display_type => 'list'
+    }
+    assert_response :success
+
+    child_level1 = css_select('tr#project-5').map {|e| e.attr('class')}.first.split(' ')
+    child_level2 = css_select('tr#project-6').map {|e| e.attr('class')}.first.split(' ')
+
+    assert_include 'idnt', child_level1
+    assert_include 'idnt-1', child_level1
+
+    assert_include 'idnt', child_level2
+    assert_include 'idnt-2', child_level2
+  end
+
   def test_autocomplete_js
     get :autocomplete, :params => {
         :format => 'js',
index f4182e560bafd7adaf9091398216bf863b72e47b..43e95d308f956c902d33487687f16364130add8b 100644 (file)
@@ -44,6 +44,17 @@ class ProjectQueryTest < ActiveSupport::TestCase
     values = query.available_filters['status'][:values]
     assert_equal ['active', 'closed'], values.map(&:first)
     assert_equal ['1', '5'], values.map(&:second)
+  end
+
+  def test_default_columns
+    q = ProjectQuery.new
+    assert q.columns.any?
+    assert q.inline_columns.any?
+    assert q.block_columns.empty?
+  end
 
+  def test_available_columns_should_include_project_custom_fields
+    query = ProjectQuery.new
+    assert_include :"project.cf_3", query.available_columns.map(&:name)
   end
 end