RedmineプラグインでWikiのサイドバーにページツリービューを表示する


# 要約
RedmineのWikiではページの親子関係を設定できます。
「索引（名前順）」ページで、親子関係を一望できます。
文章のアウトライン表示として使えそうですが、「索引（名前順）」ページはページですので、各ページの閲覧・編集中は見ることができません。特に親子関係を整理するときに見られないのが不便です。
そこで、サイドバーに同様の情報を常に表示することで、各ページの閲覧・編集中にもページの親子関係を確認できるようにします。

# ゴール

次のイメージです。

![Screen Shot 2017-10-23 at 10.18.39.png](https://qiita-image-store.s3.amazonaws.com/0/15964/610e7a7e-3cf8-1aa9-14d7-7f7b2d7c5e7e.png "Screen Shot 2017-10-23 at 10.18.39.png")

サイドバーに「索引（名前順）」ページと同等の情報を表示します。

[Github リポジトリ](https://github.com/ledsun/redmine_wiki_page_tree) はこちらです。

# 実装

## 作戦

Redimneプラグインで見た目を変えるときはView Hookを使います。
[Hooks List - Redmine](http://www.redmine.org/projects/redmine/wiki/Hooks_List)にはWikiのSidebar用のView Hookはありません。

そこで[Redmine の wiki index ページを tree view で表示する - redmine_wiki_index_tree_view - basyura's blog](http://blog.basyura.org/entry/2014/04/29/001952)を参考にします。


> ```rb
> def view_layouts_base_html_head(context)
> 	return unless context[:controller]
> 	params = context[:controller].params
> 	return unless (params[:controller] == 'wiki' && params[:action] == 'index')
> 	
>	tags = [stylesheet_link_tag('jquery.treeview.css',
>	                           :plugin => 'redmine_wiki_index_tree_view')]
>	tags << javascript_include_tag('jquery.treeview.js',
>	                           :plugin => 'redmine_wiki_index_tree_view')
>	tags << javascript_include_tag('redmine_wiki_index_tree_view.js',
>	                           :plugin => 'redmine_wiki_index_tree_view')
>	tags.join("\n")
> end
> ```

`view_layouts_base_sidebar`を使い、Wikiを表示する時だけページツリービューを表示します。


## ソースコード

```rb
class PollsHookListener < Redmine::Hook::ViewListener
  include ActionView::Helpers::DateHelper
  include ActionView::Context

  # サイドバーにページツリーを表示
  def view_layouts_base_sidebar(context = {})
    return unless context[:controller]
    params = context[:controller].params
    return unless params[:controller] == 'wiki'

    pages = load_pages(context[:request].params[:project_id])
    pages_by_parent_id = pages.group_by(&:parent_id)

    content_tag(:div, class: 'page-tree') do
      concat content_tag(:h2, 'ページツリービュー', class: 'page-tree__title')
      concat render_page_hierarchy(pages_by_parent_id, nil, :timestamp => true)
    end
  end

  # assetsの追加
  def view_layouts_base_html_head(context)
    return unless context[:controller]
    params = context[:controller].params
    return unless params[:controller] == 'wiki'

    stylesheet_link_tag('sidebar__page-tree', :plugin => 'redmine_plugin_of_yours')
  end

  private

  def load_pages(project_id)
    Project.find(project_id)
      .wiki
      .pages
      .with_updated_on
      .reorder("#{WikiPage.table_name}.title")
      .includes(:wiki => :project)
      .includes(:parent)
      .to_a
  end

  def render_page_hierarchy(pages, node=nil, options={})
    content = ''
    if pages[node]
      content << "<ul class=\"pages-tree__list\">\n"
      pages[node].each do |page|
        if pages[page.id]
          content << "<details open>"
          content << '<summary class="pages-tree__node">'
          content << link_to_wiki(page, options)
          content << "</summary>\n"
          content << "<li>"
          content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
          content << "</li>\n"
          content << "</details>\n"
        else
          content << '<li class="pages-tree__list__node--leaf">'
          content << link_to_wiki(page, options)
          content << "</li>\n"
        end
      end
      content << "</ul>\n"
    end
    content.html_safe
  end

  def link_to_wiki(page, options)
    link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
                       :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil),
                       :draggable => 'true')
  end
end
```

### ページツリービューの追加

`render_page_hierarchy`は[索引 (名前順）ページ](https://github.com/redmine/redmine/blob/master/app/views/wiki/index.html.erb#L17)で使われている、[render_page_hierarchy](https://github.com/redmine/redmine/blob/94f7cfbf990028348b9262578acbc53a94fce448/app/helpers/application_helper.rb#L314)を参考ししています。

元々ulタグ、liタグのみでレンダリングしていたのを、[detailsタグ](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details)を使い、ツリーを閉じれるように変更しています。

```rb
def render_page_hierarchy(pages, node=nil, options={})
  content = ''
  if pages[node]
    content << "<ul class=\"pages-tree__list\">\n"
    pages[node].each do |page|
      if pages[page.id]
        content << "<details open>"
        content << '<summary class="pages-tree__node">'
        content << link_to_wiki(page, options)
        content << "</summary>\n"
        content << "<li>"
        content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
        content << "</li>\n"
        content << "</details>\n"
      else
        content << '<li class="pages-tree__list__node--leaf">'
        content << link_to_wiki(page, options)
        content << "</li>\n"
      end
    end
    content << "</ul>\n"
  end
  content.html_safe
end
```

### スタイルシートの追加

見た目を調整するために`view_layouts_base_html_head`View Hookを使って、CSSファイルを追加します。
こちらも`view_layouts_base_sidebar`と同様にWikiページの時のみ追加します。

```rb
# assetsの追加
def view_layouts_base_html_head(context)
  return unless context[:controller]
  params = context[:controller].params
  return unless params[:controller] == 'wiki'

  stylesheet_link_tag('sidebar__page-tree', :plugin => 'redmine_plugin_of_yours')
end
```

```css
#sidebar .page-tree {
    margin-left: -12px;
    padding-top: 10px;
}

#sidebar .page-tree__title {
    margin-left: 12px;
}

#sidebar .pages-tree__list {
    margin: 0 12px;
}

#sidebar .pages-tree__list__node--leaf {
    list-style-type: disc;
    margin-left: 13px;
}
```

# ドラッグアンドドロップ機能を追加

ページの親子関係をドラッグアンドドロップ操作で編集できるようにします。
次のイメージです。
![out.gif](https://qiita-image-store.s3.amazonaws.com/0/15964/a84d34f6-9b34-fd9e-9b3a-8a1c84196b16.gif "out.gif")

## 作戦

### 名前変更を流用

もともとRedmineのwikiには名前変更ページに親子関係を変更する処理が入っています。
必要なパラメータを入れて `/projects/:project_id/wiki/:wiki_page_id/rename`にPOSTすれば親子関係を更新できます。

[redmine/rename.html.erb at master · redmine/redmine](https://github.com/redmine/redmine/blob/master/app/views/wiki/rename.html.erb#L7)

> ```erb
<%= labelled_form_for :wiki_page, @page,
                     :url => { :action => 'rename' },
                     :html => { :method => :post } do |f| %>
<div class="box tabular">
<p><%= f.text_field :title, :required => true, :size => 100  %></p>
<% if @page.safe_attribute? 'is_start_page' %>
<p><%= f.check_box :is_start_page, :label => :field_start_page, :disabled => @page.is_start_page %></p>
<% end %>
<p><%= f.check_box :redirect_existing_links %></p>
<p><%= f.select :parent_id,
                content_tag('option', '', :value => '') + 
                  wiki_page_options_for_select(
                    @wiki.pages.includes(:parent).to_a - @page.self_and_descendants,
                    @page.parent),
                :label => :field_parent_title %></p>
```                

JavaScriptで、このフォームを真似てform要素を作ってsubmitすれば親子関係を更新できます。

```html
<form action="/projects/:project_id/wiki/:wiki_page_id/rename" method="post">
  <input type="hidden" name="wiki_page[parent_id]" value=":parent_id">
  <input type="hidden" name="${csrfParam}" value="${csrfToken}">
</form>
```

- 移動するページのURL
- 移動先の新しく親になるページのid

が必要です。

また、RailsでのフォームPOSTにはCSRF対策のトークンも必要です。
HTMLのヘッダーに入っているので、これを使います。

```html
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="j6FeGnAWw0x5MUBOvViQOkZNjKlgTeSpJaH9aSL0h9cy2oFrqwLRQDP00u1Jpn6lKVGv6Xft9UUZnXAAbImzmA==">
```

### ドラッグアンドドロップ

ドラッグアンドドロップはHTML5の[ドラッグアンドドロップAPI](https://developer.mozilla.org/ja/docs/Web/API/HTML_Drag_and_Drop_API)を使って実装します。

## 実装

### 属性追加

2. ドラッグ可能にするため`draggable`属性を追加して`true`に設定
1. ドロップ先のparent_idを知りたいので、`data-wiki-page-id`属性を追加してaタグに埋め込む

```rb
def link_to_wiki(page, options)
  link_to(
    h(page.pretty_title),
    {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
    :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil),
    :draggable => 'true',
    :data => { :wiki_page_id => page.id })
end
```

### JavaScriptファイル追加

JavaScriptファイルを追加します。
CSSファイルと一緒です。


```rb
def view_layouts_base_html_head(context)
  return unless context[:controller]
  params = context[:controller].params
  return unless params[:controller] == 'wiki'

  tags = []
  tags << stylesheet_link_tag('sidebar__page-tree', :plugin => 'redmine_plugin_of_yours')
  tags << javascript_include_tag('sidebar__page-tree', :plugin => 'redmine_plugin_of_yours')
  tags.join("\n")
end

```

### ドラッグアンドドロップの実装

JavaScriptでドラッグアンドドロップの動作を実装します。


```js
document.addEventListener('DOMContentLoaded', () => {
  Array.from(document.querySelectorAll('.page-tree a'))
    .forEach(a => {
      // ドッラグ開始時にドラッグ中要素の情報を記録
      a.addEventListener('dragstart', (e) => {
        e.dataTransfer.setData('url', e.target.getAttribute('href'))
      })

      // ドラッグオーバーのイベントリスナーでpreventDefaultすると、ドロップ可能になります。
      a.addEventListener('dragover', (e) => {
        e.preventDefault()
      })

      // ドロップ時の処理
      a.addEventListener('drop', (e) => {
        const url = e.dataTransfer.getData('url') // 移動するwikiページのURL
        const parentId = e.target.getAttribute('data_wiki_page_id') // 親ページのID

        // CSRF対策用トークン
        const csrfParam = document.querySelector('[name="csrf-param"]').getAttribute('content')
        const csrfToken = document.querySelector('[name="csrf-token"]').getAttribute('content')

        // formを作る
        const div = document.createElement('div')
        div.innerHTML = `
          <form action="${url}/rename" method="post">
            <input type="hidden" name="wiki_page[parent_id]" value="${parentId}">
            <input type="hidden" name="${csrfParam}" value="${csrfToken}">
          </form>
        `

        // formをsubmitする
        document.body.appendChild(div)
        div.querySelector('form')
          .submit()
      })
    })
})
```


# 参考
- [Redmineプラグインの開発環境構築 - Qiita](https://qiita.com/ledsun/items/2f9d49679906ee36bc1e)