Redmine: チケット一覧でカスタムフィールドの値に応じて行のフォントを太字に変える(View customize plugin)

カスタムフィールドの値に応じて行を装飾する方法について問い合わせをいただいたので、「カスタムフィールド1の値が'A'の場合には、行のフォントを太字に変える」をView customizeで行ってみます。

チケットの優先度など、デフォルトで存在するフィールドについては、もとからclassが振られているのでCSSで装飾しやすいですが、カスタムフィールドだとclassが振られていないので、CSSだけではできません。 そのためJavaScriptにて値を判定して、スタイルを設定することによって対応します。

View customize の設定内容

Path pattern

チケット一覧を対象にします。

/issues$

Code

Type:JavaScriptとして下記を設定します。cf_1の部分は、該当のカスタムフィールドに応じて変更してください。

$(function() {
  $('table.issues td.cf_1')
    .filter(function() {
      return $(this).text() == 'A';
    })
    .parent()
    .css('color', 'red');
});

設定後のイメージ

f:id:onozaty:20170530000920p:plain

一覧に該当のカスタムフィールドが表示されていないと判定ができないので、ご注意ください。

その他

複数の属性を指定するような場合だと、JavaScriptだと面倒かと思います。そういった場合には、JavaScript側ではclassを追加するだけで、そのclassに応じたCSSを別途定義しておくような形がよいかと思います。

MyBatis にて size というパラメータ名を使うとintとしてマッピングされてしまう

追記@2017-07-02

下記Issueで既に対応していただいています。次のバージョン(3.4.5)に入る予定です。


MyBatisのSQLへのパラメータ埋め込みで、sizeという名前を使うと、JavaTypeがintとなってしまうようです。

現象を確認したのは、mybatis-spring-boot-starterの1.3.0(MyBatis自体のバージョンは3.4.4)です。

問題となるコード

下記のようなMapperを作ります。sizeという名前で、long型の引数をマッピングします。

package com.example.demo;

import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

@Repository
@Mapper
public interface TestRepository {

    @Insert("INSERT INTO test(number, size) VALUES(#{number}, #{size})")
    public void insert(@Param("number") long number, @Param("size") long size);
}

上記のメソッドを呼び出すと、下記のようなエラーが発生します。

org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.type.TypeException: Could not set parameters for mapping: ParameterMapping{property='size', mode=IN, javaType=int, jdbcType=null, numericScale=null, resultMapId='null', jdbcTypeName='null', expression='null'}. Cause: org.apache.ibatis.type.TypeException: Error setting non null for parameter #2 with JdbcType null . Try setting a different JdbcType for this parameter or a different configuration property. Cause: java.lang.ClassCastException: java.lang.Long cannot be cast to java.lang.Integer

なぜかsizeがintとしてマッピングされようとしてClassCastExceptionが発生しています。LongTypeHandlerが使われるべきなのに、IntTypeHandlerが使用されてしまっているようです。

試しに下記のように別の名前に変えるとうまく動作します。

    @Insert("INSERT INTO test(number, size) VALUES(#{number}, #{size2})")
    public void insert2(@Param("number") long number, @Param("size2") long size);

sizeという名前は、何か特別なものとして使われていて、それと競合することによっておきてしまっているのかもしれません。

Redmineでニュースの参照可否を制御する(patch適用)

Redmine 3.3時点では、ニュースの参照に関する権限が無く、一部のユーザにニュースを見せないということが出来ません。

それをつぶやいたところ、前田さんにRedmine本家のFeatureにあがっていて、3.4.0で入るかもといった情報を頂いたので、先行してパッチを当てて試してみました。

パッチの内容

現時点のパッチは下記になっています。

軽微な変更だったのと、最新じゃないバージョンに当てたかったので、手作業で差分当てました。下記の3ファイル(合計3行)さえ直せば、すぐに試せます。(日本語の表示確認してなくて良ければ、ja.ymlもいらないです)

  • config/locales/en.yml
diff --git a/config/locales/en.yml b/config/locales/en.yml
index a8eca2dfe..f2a8cbc0b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -497,6 +497,7 @@ en:
   permission_view_time_entries: View spent time
   permission_edit_time_entries: Edit time logs
   permission_edit_own_time_entries: Edit own time logs
+  permission_view_news: View news
   permission_manage_news: Manage news
   permission_comment_news: Comment news
   permission_view_documents: View documents
  • config/locales/ja.yml
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index a018e7240..8d1ae6ce1 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -427,6 +427,7 @@ ja:
   permission_edit_time_entries: 作業時間の編集
   permission_edit_own_time_entries: 自身が記入した作業時間の編集
   permission_manage_project_activities: 作業分類 (時間管理) の管理
+  permission_view_news: ニュースの閲覧
   permission_manage_news: ニュースの管理
   permission_comment_news: ニュースへのコメント
   permission_view_documents: 文書の閲覧
  • lib/redmine.rb
diff --git a/lib/redmine.rb b/lib/redmine.rb
index e3ff60fd1..204429cc6 100644
--- a/lib/redmine.rb
+++ b/lib/redmine.rb
@@ -131,7 +131,7 @@ Redmine::AccessControl.map do |map|
   end
 
   map.project_module :news do |map|
-    map.permission :view_news, {:news => [:index, :show]}, :public => true, :read => true
+    map.permission :view_news, {:news => [:index, :show]}, :read => true
     map.permission :manage_news, {:news => [:new, :create, :edit, :update, :destroy], :comments => [:destroy], :attachments => :upload}, :require => :member
     map.permission :comment_news, {:comments => :create}
   end

対応後の画面

ロールの編集画面に「ニュースの閲覧」という権限が追加されて、制御可能になりました。実際のニュースの表示もばっちりです。

f:id:onozaty:20170514004703p:plain

Spring Boot で WebSocket (STOMPではなく、TextWebSocketHandler)を試してみる

こないだSTOMP over WebSocketを試してみましたが、今度はSTOMPを使わずに、TextWebSocketHandlerを使ったWebSocketを試してみます。題材も同じくチャットです。

ルームの情報をどのように渡そうか悩みました。コードを書き始めるまでは、WebSocketで接続する先にヘッダとかで渡せばいいかなと思っていましたが、調べてみたら渡せなかったので、やもえずURLのクエリパラメータとして渡すようにしました。

コード

テキストでのやり取りを行うので、TextWebSocketHandler を利用します。TextWebSocketHandlerの各メソッドをoverrideして必要な処理を実装するだけです。

ルームの情報は、URLのクエリとしてクライアントから送っているので、接続が確立したタイミング(afterConnectionEstablished)にて、ルーム毎にWebSocketSessionを保持するようにします。 メッセージを受け取ったら、自分のルームと同じWebSocketSessionに対して、メッセージを送るだけです。

package com.example;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

@Component
public class ChatHandler extends TextWebSocketHandler {

    private ConcurrentHashMap<String, Set<WebSocketSession>> roomSessionPool = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {

        String roomName = session.getUri().getQuery();

        roomSessionPool.compute(roomName, (key, sessions) -> {

            if (sessions == null) {
                sessions = new CopyOnWriteArraySet<>();
            }
            sessions.add(session);

            return sessions;
        });
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {

        String roomName = session.getUri().getQuery();

        for (WebSocketSession roomSession : roomSessionPool.get(roomName)) {
            roomSession.sendMessage(message);
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {

        String roomName = session.getUri().getQuery();

        roomSessionPool.compute(roomName, (key, sessions) -> {

            sessions.remove(session);
            if (sessions.isEmpty()) {
                // 1件もない場合はMapからクリア
                sessions = null;
            }

            return sessions;
        });
    }
}

WebSocketの設定として、URLとHandlerを紐付けます。

package com.example;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

import lombok.AllArgsConstructor;

@Configuration
@EnableWebSocket
@AllArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatHandler chatHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatHandler, "/endpoint");
    }

}

クライアント側では、下記のようなコードになりました。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>チャット</title>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css" />
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css" />
</head>
<body>
  <div class="container">
    <h2>チャット</h2>
    <div class="form-horizontal">
      <div class="form-group">
        <label for="roomName" class="col-sm-2 control-label">ルーム</label>
        <div class="col-sm-2">
          <input id="roomName" type="text" class="form-control" value="example" />
        </div>
        <div class="col-sm-3">
          <button id="connectButton" type="button" class="btn btn-default">接続</button>
          <button id="disconnectButton" class="btn btn-default">切断</button>
        </div>
      </div>
      <div class="form-group">
        <label for="message" class="col-sm-2 control-label">メッセージ</label>
        <div class="col-sm-4">
          <input id="message" type="text" class="form-control" />
        </div>
        <div class="col-sm-2">
          <button id="sendButton" type="button" class="btn btn-default">送信</button>
        </div>
      </div>
      <div class="row">
        <div class="col-sm-4 col-sm-offset-2">
          <ul id="messageList" class="list-unstyled">
          </ul>
        </div>
      </div>
    </div>
  </div>
  <script src="/webjars/jquery/1.12.4/jquery.min.js"></script>
  <script src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
  <script>
    $(function() {
      var endpoint = 'ws://' + location.host + '/endpoint';
      var webSocket = null;

      $('#connectButton').click(function() {

        $("#messageList").empty();

        webSocket = new WebSocket(endpoint + '?' + encodeURIComponent($('#roomName').val()));
        webSocket.onopen = function() {
          $('#roomName').prop('disabled', true);
          $('#connectButton').prop('disabled', true);
          $('#disconnectButton').prop('disabled', false);
        };
        webSocket.onclose = function() {
        };
        webSocket.onmessage = function(message) {
          $('#messageList').prepend($('<li>').text(message.data));
        };
        webSocket.onerror = function() {
          alert('エラーが発生しました。');
        };
      });

      $('#disconnectButton').click(function() {

        webSocket.close();
        webSocket = null;

        $('#roomName').prop('disabled', false);
        $('#connectButton').prop('disabled', false);
        $('#disconnectButton').prop('disabled', true);
      });

      $('#sendButton').click(function() {
        if (!webSocket) {
          alert('未接続です。');
          return;
        }

        webSocket.send($('#message').val());
      });
    });
  </script>
</body>
</html>

STOMPの時のほうが、いろいろシンプルに書けるので、ブラウザがクライアントならば、STOMPを使わない理由はないかなと思っています。

Spring Boot で STOMP Over WebSocket を試してみる

Spring BootSTOMP Over WebSocketを使って、試しにチャットっぽいものを作ってみました。

f:id:onozaty:20170503222047p:plain

同じルームに接続しているクライアントに、同じメッセージを配信するだけならば、Controllerの定義はいりません。

WebSocketConfigとして、エンドポイントとSimpleBrokerでハンドリングする宛先を定義するだけです。

package com.example;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

@EnableWebSocketMessageBroker
@Configuration
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/endpoint");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");
    }
}

画面側では、配信と購読で同じ宛先を指定しておきます。自分で送ったものも受け取ることになります。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>チャット</title>
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap.min.css" />
<link rel="stylesheet" href="/webjars/bootstrap/3.3.7/css/bootstrap-theme.min.css" />
</head>
<body>
  <div class="container">
    <h2>チャット</h2>
    <div class="form-horizontal">
      <div class="form-group">
        <label for="roomName" class="col-sm-2 control-label">ルーム</label>
        <div class="col-sm-2">
          <input id="roomName" type="text" class="form-control" value="example" />
        </div>
        <div class="col-sm-3">
          <button id="connectButton" type="button" class="btn btn-default">接続</button>
          <button id="disconnectButton" class="btn btn-default">切断</button>
        </div>
      </div>
      <div class="form-group">
        <label for="message" class="col-sm-2 control-label">メッセージ</label>
        <div class="col-sm-4">
          <input id="message" type="text" class="form-control" />
        </div>
        <div class="col-sm-2">
          <button id="sendButton" type="button" class="btn btn-default">送信</button>
        </div>
      </div>
      <div class="row">
        <div class="col-sm-4 col-sm-offset-2">
          <ul id="messageList" class="list-unstyled">
          </ul>
        </div>
      </div>
    </div>
  </div>
  <script src="/webjars/jquery/1.12.4/jquery.min.js"></script>
  <script src="/webjars/stomp-websocket/2.3.3-1/stomp.js"></script>
  <script src="/webjars/bootstrap/3.3.7/js/bootstrap.min.js"></script>
  <script>
    $(function() {
      var endpoint = 'ws://' + location.host + '/endpoint';
      var subscribePrefix = '/topic/';
      var stompClient = null;

      $('#connectButton').click(function() {

        $("#messageList").empty();

        stompClient = Stomp.over(new WebSocket(endpoint));
        stompClient.connect({}, function() {
          stompClient.subscribe(
              subscribePrefix + $('#roomName').val(),
              function(message) {
                $('#messageList').prepend($('<li>').text(message.body));
              });

          $('#roomName').prop('disabled', true);
          $('#connectButton').prop('disabled', true);
          $('#disconnectButton').prop('disabled', false);
        });
      });

      $('#disconnectButton').click(function() {

        stompClient.disconnect();
        stompClient = null;

        $('#roomName').prop('disabled', false);
        $('#connectButton').prop('disabled', false);
        $('#disconnectButton').prop('disabled', true);
      });

      $('#sendButton').click(function() {
        if (!stompClient) {
          alert('未接続です。');
          return;
        }

        stompClient.send(subscribePrefix + $('#roomName').val(), {}, $('#message').val());
      });
    });
  </script>
</body>
</html>

クライアント間でメッセージをやり取りするといったことが、少しのコードで簡単に実現できますね。

POPSPRING 2017 (2017年3月25日@幕張メッセ)

POPSPRING 2017 幕張公演に、嫁さんと2人で参加してきました。フェスは初参加です。

GOLDチケットだったので、とても余裕もって見れました。最初は前の方にいたのですが、逆に人が密集していてみずらかったので、途中からちょっと後ろ目で見ていました。それでもこんな感じで十分近くに見えました。

11時から21時過ぎまでの長丁場だったので、疲れたらちょっと下がって座って見れるのも良かったです。

POPSPRINGでは、アーティストの写真撮影NGとのことだったので、今回は写真がほとんど撮れませんでした。 海外アーティストだと珍しいなぁと思いましたが、いろんなアーティストが出る都合上、NGにせざるを得ないのかもしれませんね。(周りで結構撮っている人がいたけど、特に注意が入ることもなく、、、)

タイムテーブル

Opeging

会場前から並んで参加しました。GOLDだと入場も優先だったので、会場ぎりぎりでもスムーズに入れました。

DJ SHOTA(@djshotamusic)のOpening DJで、かなりテンションあがりました。早く来たかいがありました。

続くOpening Actは FlowBack、RIRI、FAKY と日本人アーティストが続きましたが、知らなかったアーティストと出会えるのはいいですね。とくにRIRI(@riri_tone)が良かったです。

まだ17歳なのにびっくりなのと、歌のときの大人の雰囲気と、MCのときの可愛さのギャップに、すぐにファンというか、応援したくなりました。可愛くて歌はパワフルなところが、アリアナ・グランデに似てるねと、嫁さんと盛り上がりました。

Jordan Fisher

機器のトラブルでかなり遅れて、Jordan Fisherとなりました。POPSPRINGに出演するということで、初めて知ったアーティストでしたが、All About UsをYouTubeで見て、すぐに好きになりました。

Jordan Fisher - All About Us (Official Video)

ちなみにダンスみて、なぜかUsherを思い出しました。似てるかな、、

パフォーマンスを見て、これからさらに売れるんだろうなぁと確信しました。また見たい!

Da-iCE

ちょうどお昼休憩とっていて、ほとんど見逃してしまいました、、ごめんなさい。

Austin Mahone

昨年に続いてのAustin Mahone。ダンサーさんが色っぽい。

Dirty Work でブルゾンちえみが出てこないかなぁと思いましたが、出てきませんでした(笑)。

最後に発表となったアーティストで、まぁ、Austin最後に持ってこられたら、全てOKになりますよね!(日本人アーティストが多いことで、ちょっと話題になっていましたが、最後のAustinの発表で全て吹き飛んだ気が)

Sabrina Carpenter

Sabrina Carpenter もPOPSPRINGに出演するということで初めて知ったアーティストでしたが、ほんと今となってはすごい好きなアーティストになりました。とくに1stアルバムの曲に好きなのが多いです。

Can’t Blame a Girl for Trying や Eyes Wide Open とか。

Sabrina Carpenter - Eyes Wide Open (Official Video)

ステージのSabrinaはすごい輝いてました。

w-inds.

We Don’t Need To Talk Anymore を初めて聞きましたが、これはいい曲だと思いました。

We Don’t Need To Talk Anymore(MUSIC VIDEO Full ver.+15s SPOT) / w-inds.

DNCE

一番盛り上がったと思います。楽しくて、一番疲れました(笑)。ほんと弾けすぎです。

Shown Mendes

いや、ほんとすごいというか、感動しました。

曲は好きで良く聞いてましたが、ギター一本であんな聞かせるパフォーマンスするなんて。すいません、全然Shownのことわかって無かったです。

ずっとハッピ姿のままいくとも思ってませんでした。ちょっとパフォーマンスとアンマッチな気が。

Fifth Harmony

Fifth Harmonyが一番のお目当てだったので、待ちに待った… といった感じでした。

いろいろな形でのパフォーマンスを見せてくれて、疲れがふっとびました。4人みんな特徴があって、いいグループですよね!(Allyのキュートな声が、一番好きです)

終わりに

自分が知らなかったアーティストに出会えたこともあって、ほんと値段以上の価値を感じたフェスでした。 また来年も行きたいです!!(サマソニにも興味がわいてきています…)

参考リンク

Lombokの@Builder(toBuilder = true)で、オブジェクトの複製を作成する

前回の記事の続きで。

@Builder(toBuilder = true)とすると、オブジェクトからBuilderを生成できます。

どういうときに使えるかというと、オブジェクトの複製を作成して、一部のプロパティのみ変えたいといった場合です。

package com.example.lombok;

import lombok.Builder;
import lombok.ToString;

public class BuilderExample {

    public static void main(String[] args) {

        Customer customer1 = new Customer("Taro", "Urashima", "ura@exmple.com", "111-111-1111");

        System.out.println(customer1);

        Customer customer2 = customer1.toBuilder()
                .lastName("Yamada")
                .build();

        System.out.println(customer2);
    }
}

@Builder(toBuilder = true)
@ToString
class Customer {

    private String firstName;

    private String lastName;

    private String mailAddress;

    private String telephone;
}

実行結果は下記の通りです。lastNameだけが異なるオブジェクトが生成されます。

Customer(firstName=Taro, lastName=Urashima, mailAddress=ura@exmple.com, telephone=111-111-1111)
Customer(firstName=Taro, lastName=Yamada, mailAddress=ura@exmple.com, telephone=111-111-1111)

Lombokで生成されるのは、下記のようなコードになります。(delombokで確認)

class Customer {
    private String firstName;
    private String lastName;
    private String mailAddress;
    private String telephone;

    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    Customer(final String firstName, final String lastName, final String mailAddress, final String telephone) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.mailAddress = mailAddress;
        this.telephone = telephone;
    }


    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public static class CustomerBuilder {
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String firstName;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String lastName;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String mailAddress;
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        private String telephone;

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        CustomerBuilder() {
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder firstName(final String firstName) {
            this.firstName = firstName;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder lastName(final String lastName) {
            this.lastName = lastName;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder mailAddress(final String mailAddress) {
            this.mailAddress = mailAddress;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public CustomerBuilder telephone(final String telephone) {
            this.telephone = telephone;
            return this;
        }

        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public Customer build() {
            return new Customer(firstName, lastName, mailAddress, telephone);
        }

        @java.lang.Override
        @java.lang.SuppressWarnings("all")
        @javax.annotation.Generated("lombok")
        public java.lang.String toString() {
            return "Customer.CustomerBuilder(firstName=" + this.firstName + ", lastName=" + this.lastName + ", mailAddress=" + this.mailAddress + ", telephone=" + this.telephone + ")";
        }
    }

    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public static CustomerBuilder builder() {
        return new CustomerBuilder();
    }

    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public CustomerBuilder toBuilder() {
        return new CustomerBuilder().firstName(this.firstName).lastName(this.lastName).mailAddress(this.mailAddress).telephone(this.telephone);
    }

    @java.lang.Override
    @java.lang.SuppressWarnings("all")
    @javax.annotation.Generated("lombok")
    public java.lang.String toString() {
        return "Customer(firstName=" + this.firstName + ", lastName=" + this.lastName + ", mailAddress=" + this.mailAddress + ", telephone=" + this.telephone + ")";
    }
}