Yi EungJun 2013-01-28
Rewrite the Pagination.
Apply this new one to issue, board and search.

For more details, See docs/technical/pagination.md
@7516ce3b68d92d3df91c25c46ea62598203f8b10
app/controllers/BoardApp.java
--- app/controllers/BoardApp.java
+++ app/controllers/BoardApp.java
@@ -6,6 +6,8 @@
 
 import java.io.File;
 
+import com.avaje.ebean.Page;
+
 import models.Attachment;
 import models.Comment;
 import models.Post;
@@ -50,21 +52,19 @@
     }
 
 
-    public static Result posts(String userName, String projectName) {
+    public static Result posts(String userName, String projectName, int pageNum) {
         Form<SearchCondition> postParamForm = new Form<SearchCondition>(SearchCondition.class);
         SearchCondition postSearchCondition = postParamForm.bindFromRequest().get();
+        postSearchCondition.pageNum = pageNum - 1;
         Project project = ProjectApp.getProject(userName, projectName);
 
         if (!AccessControl.isCreatable(User.findByLoginId(session().get("loginId")), project, Resource.BOARD_POST)) {
             return unauthorized(views.html.project.unauthorized.render(project));
         }
 
-        Logger.debug(postSearchCondition.filter);
-        return ok(postList.render(
-                "menu.board",
-                project,
-                Post.findOnePage(project.owner, project.name, postSearchCondition.pageNum,
-                        Direction.getValue(postSearchCondition.order), postSearchCondition.key, postSearchCondition.filter), postSearchCondition));
+        Page<Post> posts = Post.findOnePage(project.owner, project.name, postSearchCondition.pageNum,
+                        Direction.getValue(postSearchCondition.order), postSearchCondition.key, postSearchCondition.filter);
+        return ok(postList.render("menu.board", project, posts, postSearchCondition));
     }
 
     public static Result newPostForm(String userName, String projectName) {
@@ -89,7 +89,6 @@
             post.authorLoginId = UserApp.currentUser().loginId;
             post.authorName = UserApp.currentUser().name;
             post.commentCount = 0;
-            post.filePath = saveFile(request());
             post.project = project;
             Post.write(post);
 
@@ -97,7 +96,7 @@
             Attachment.attachFiles(UserApp.currentUser().id, project.id, Resource.BOARD_POST, post.id);
         }
 
-        return redirect(routes.BoardApp.posts(project.owner, project.name));
+        return redirect(routes.BoardApp.posts(project.owner, project.name, 1));
     }
 
     public static Result post(String userName, String projectName, Long postId) {
@@ -109,7 +108,7 @@
 
         if (post == null) {
             flash(Constants.WARNING, "board.post.notExist");
-            return redirect(routes.BoardApp.posts(project.owner, project.name));
+            return redirect(routes.BoardApp.posts(project.owner, project.name, 1));
         } else {
             Form<Comment> commentForm = new Form<Comment>(Comment.class);
             return ok(views.html.board.post.render(post, commentForm, project));
@@ -129,7 +128,6 @@
             comment.authorId = UserApp.currentUser().id;
             comment.authorLoginId = UserApp.currentUser().loginId;
             comment.authorName = UserApp.currentUser().name;
-            comment.filePath = saveFile(request());
 
             Comment.write(comment);
             Post.countUpCommentCounter(postId);
@@ -145,7 +143,7 @@
         Project project = ProjectApp.getProject(userName, projectName);
         Post.delete(postId);
         Attachment.deleteAll(Resource.BOARD_POST, postId);
-        return redirect(routes.BoardApp.posts(project.owner, project.name));
+        return redirect(routes.BoardApp.posts(project.owner, project.name, 1));
     }
 
     public static Result editPostForm(String userName, String projectName, Long postId) {
@@ -174,7 +172,6 @@
             post.authorId = UserApp.currentUser().id;
             post.authorName = UserApp.currentUser().name;
             post.id = postId;
-            post.filePath = saveFile(request());
             post.project = project;
 
             Post.edit(post);
@@ -183,7 +180,7 @@
             Attachment.attachFiles(UserApp.currentUser().id, project.id, Resource.BOARD_POST, post.id);
         }
 
-        return redirect(routes.BoardApp.posts(project.owner, project.name));
+        return redirect(routes.BoardApp.posts(project.owner, project.name, 1));
 
     }
 
@@ -192,19 +189,6 @@
         Post.countDownCommentCounter(postId);
         Attachment.deleteAll(Resource.BOARD_COMMENT, commentId);
         return redirect(routes.BoardApp.post(userName, projectName, postId));
-    }
-
-    private static String saveFile(Request request) {
-        MultipartFormData body = request.body().asMultipartFormData();
-
-        FilePart filePart = body.getFile("filePath");
-
-        if (filePart != null) {
-            File saveFile = new File("public/uploadFiles/" + filePart.getFilename());
-            filePart.getFile().renameTo(saveFile);
-            return filePart.getFilename();
-        }
-        return null;
     }
 
 }
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -57,7 +57,7 @@
      * @throws IOException
      * @throws WriteException
      */
-    public static Result issues(String userName, String projectName, String state, String format) throws WriteException, IOException {
+    public static Result issues(String userName, String projectName, String state, String format, int pageNum) throws WriteException, IOException {
         Project project = ProjectApp.getProject(userName, projectName);
 
         if (!AccessControl.isAllowed(UserApp.currentUser(), project.asResource(), Operation.READ)) {
@@ -69,6 +69,7 @@
         OrderParams orderParams = new OrderParams().add(issueParam.sortBy,
                 Direction.getValue(issueParam.orderBy));
         issueParam.state = state;
+        issueParam.pageNum = pageNum - 1;
 
         String[] labelIds = request().queryString().get("labelIds");
         if (labelIds != null) {
@@ -76,7 +77,6 @@
                 issueParam.labelIds.add(Long.valueOf(labelId));
             }
         }
-
 
         if (format.equals("xls")) {
             return issuesAsExcel(issueParam, orderParams, project, state);
@@ -159,7 +159,7 @@
             Attachment.attachFiles(UserApp.currentUser().id, project.id, Resource.ISSUE_POST, issueId);
         }
         return redirect(routes.IssueApp.issues(project.owner, project.name,
-                State.OPEN.state(), "html"));
+                State.OPEN.state(), "html", 1));
     }
 
     public static Result editIssueForm(String userName, String projectName, Long id) {
@@ -207,7 +207,7 @@
         // Attach the files in the current user's temporary storage.
         Attachment.attachFiles(UserApp.currentUser().id, originalIssue.project.id, Resource.ISSUE_POST, id);
 
-        return redirect(routes.IssueApp.issues(originalIssue.project.owner, originalIssue.project.name, State.OPEN.name(), "html"));
+        return redirect(routes.IssueApp.issues(originalIssue.project.owner, originalIssue.project.name, State.OPEN.name(), "html", 1));
     }
 
     public static Result deleteIssue(String userName, String projectName, Long issueId) {
@@ -216,7 +216,7 @@
         Issue.delete(issueId);
         Attachment.deleteAll(Resource.ISSUE_POST, issueId);
         return redirect(routes.IssueApp.issues(project.owner, project.name,
-                State.OPEN.state(), "html"));
+                State.OPEN.state(), "html", 1));
     }
 
     public static Result newComment(String userName, String projectName, Long issueId) throws IOException {
app/controllers/ProjectApp.java
--- app/controllers/ProjectApp.java
+++ app/controllers/ProjectApp.java
@@ -50,7 +50,7 @@
         return ok(projectHome.render("title.projectHome",
                 getProject(userName, projectName)));
     }
-    
+
     @Cached(key = "newProjectForm")
     public static Result newProjectForm() {
         if (session().get(UserApp.SESSION_USERID) == null) {
app/controllers/SearchApp.java
--- app/controllers/SearchApp.java
+++ app/controllers/SearchApp.java
@@ -1,5 +1,8 @@
 package controllers;
 
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
 import com.avaje.ebean.*;
 import models.*;
 import play.mvc.*;
@@ -23,7 +26,7 @@
         }
     }
 
-    public static Result contentsSearch(String userName, String projectName) {
+    public static Result contentsSearch(String userName, String projectName, int page) {
         Project project = ProjectApp.getProject(userName, projectName);
 
         if (project == null) {
@@ -31,6 +34,20 @@
         }
 		/* @TODO 쿼리에 대해서 특수문자나 공백 체크 해야함. */
         ContentSearchCondition condition = form(ContentSearchCondition.class).bindFromRequest().get();
+
+        String range = request().getHeader("Range");
+        if (range != null) {
+            String regex = "pages=(.*)";
+            Pattern pattern = Pattern.compile(regex);
+            Matcher match = pattern.matcher(range);
+            if (match.matches()) {
+                int pageNum = Integer.parseInt(match.group(1));
+                if (condition.page != 0 && condition.page != pageNum) {
+                    play.Logger.warn("Conflict error: condition.page values from query string and from Range header are different.");
+                }
+                condition.page = pageNum;
+            }
+        }
 
         Page<Issue> resultIssues = null;
         Page<Post> resultPosts = null;
@@ -42,12 +59,22 @@
             resultPosts = Post.find(project, condition);
         }
 
-        if(condition.type.equals("post")) {
-            return ok(postContentsSearch.render(project, resultPosts));
-        } else if(condition.type.equals("issue")) {
-            return ok(issueContentsSearch.render(project, resultIssues));
+        response().setHeader("Accept-Range", "pages");
+
+        if (condition.type.equals("post")) {
+            response().setHeader(
+                    "Content-Range",
+                    "pages " + Integer.toString(resultPosts.getPageIndex() + 1) + "/"
+                            + Integer.toString(resultPosts.getTotalPageCount()));
+            return status(206, postContentsSearch.render(project, resultPosts));
+        } else if (condition.type.equals("issue")) {
+            response().setHeader(
+                    "Content-Range",
+                    "pages " + Integer.toString(resultIssues.getPageIndex() + 1) + "/"
+                            + Integer.toString(resultIssues.getTotalPageCount()));
+            return status(206, issueContentsSearch.render(project, resultIssues));
         }
 
         return ok(contentsSearch.render("title.contentSearchResult", project, condition.filter, resultIssues, resultPosts));
     }
-}
(No newline at end of file)
+}
app/models/Post.java
--- app/models/Post.java
+++ app/models/Post.java
@@ -77,7 +77,7 @@
             .add("project.name", projectName, Matching.EQUALS)
             .add("contents", filter, Matching.CONTAINS);
         OrderParams orderParams = new OrderParams().add(key, direction);
-        return FinderTemplate.getPage(orderParams, searchParam, finder, 10, pageNum - 1);
+        return FinderTemplate.getPage(orderParams, searchParam, finder, 10, pageNum);
     }
 
     public static Long write(Post post) {
app/views/board/postList.scala.html
--- app/views/board/postList.scala.html
+++ app/views/board/postList.scala.html
@@ -1,7 +1,10 @@
 @(title:String, project:Project, page:com.avaje.ebean.Page[Post], param:BoardApp.SearchCondition)
 
+@play.Logger.debug("board view")
+
 @import utils.TemplateHelper._
 @import utils.AccessControl._
+@import scala.collection.immutable.Map
 
 @header(label:String, key:String) = {
   <th>
@@ -78,9 +81,15 @@
     <a href="@routes.BoardApp.newPostForm(project.owner, project.name)" class="n-btn blue small">WRITE</a>
   </div>
   }
-  @pagination(page, 5, "pagination")
+  <div id="pagination">
+    <!-- pagination.js will fill here. -->
+  </div>
 </div>
+  <script src="@getJSLink("pagination")" type="text/javascript"></script>
   <script type="text/javascript">
   nforge.require('board.list');
+  $(document).ready(function() {
+    Pagination.update($('#pagination'), @page.getTotalPageCount);
+  });
   </script>
 }
app/views/issue/issueList.scala.html
--- app/views/issue/issueList.scala.html
+++ app/views/issue/issueList.scala.html
@@ -3,8 +3,9 @@
 @import helper._
 @implicitFieldConstructor = @{ FieldConstructor(twitterBootstrapInput.render) } 
 @import utils.TemplateHelper._
+@import scala.collection.immutable.Map
 
-@urlToList = {@routes.IssueApp.issues(project.owner, project.name, param.state)}
+@urlToList = {@routes.IssueApp.issues(project.owner, project.name, param.state, "html", currentPage.getPageIndex + 1)}
 
 @ordering(label:String, sortBy:String) = {
   @if(sortBy == param.sortBy) {
@@ -133,12 +134,18 @@
   <div class="pull-right">
     <a class="btn btn-primary" href="@routes.IssueApp.newIssueForm(project.owner, project.name)" >@Messages("issue.menu.new")</a>
   </div>
-  @pagination(currentPage, 5, "pagination")
+  <div id="pagination">
+    <!-- pagination.js will fill here. -->
+  </div>
 </div>
 
+<script src="@getJSLink("pagination")" type="text/javascript"></script>
 <script type="text/javascript">
   nforge.require('issue.label', '@routes.IssueLabelApp.labels(project.owner, project.name)', '@routes.IssueLabelApp.newLabel(project.owner, project.name)', {editable: true});
   nforge.require('issue.list');
+  $(document).ready(function() {
+    Pagination.update($('#pagination'), @currentPage.getTotalPageCount);
+  });
 </script>
 </div>
 }
app/views/layout.scala.html
--- app/views/layout.scala.html
+++ app/views/layout.scala.html
@@ -21,7 +21,6 @@
     <script src="@getJSLink("modules/common")" type="text/javascript"></script>
     <script src="@getJSLink("modules/issue")" type="text/javascript"></script>
     <script src="@getJSLink("modules/board")" type="text/javascript"></script>
-    <script src="@getJSLink("modules/pages")" type="text/javascript"></script>
     <script src="@getJSLink("modules/project")" type="text/javascript"></script>
     <script src="@getJSLink("modules/milestone")" type="text/javascript"></script>
     <script src="@getJSLink("modules/code")" type="text/javascript"></script>
app/views/pagination.scala.html
--- app/views/pagination.scala.html
+++ app/views/pagination.scala.html
@@ -1,54 +1,33 @@
-@(page:com.avaje.ebean.Page[_ <: play.db.ebean.Model], pageNum:Int, divId:String)
+@(page:com.avaje.ebean.Page[_ <: play.db.ebean.Model], divId:String, params: Map[String, String] = scala.collection.immutable.Map[String, String](), action: (Int) => play.api.mvc.Call, submit: String = "")
 
 @{
     var currentPageNum = page.getPageIndex + 1
     var lastPageNum = page.getTotalPageCount()
-    var str = ""
-    
-    if(currentPageNum <= pageNum/2) {
-        currentPageNum = pageNum/2 +1
-    } else if(currentPageNum > lastPageNum - pageNum/2) {
-        currentPageNum = lastPageNum - pageNum/2 - 1
-    }
     makeList(currentPageNum)
 }
 @makeList(currentPageNum:Int) = {
     <div class="page-navigation-wrap" id="@divId" data-current-page-num="@currentPageNum">
+      <form method="GET" action="@action(1)">
+      @for(param <- params) {
+      <input type="hidden" name="@param._1" value="@param._2">
+      }
       <ul class="page-nums">
-        <!--
-        <li class="page-num ikon"><i class="ico btn-pg-first off"></i></li>
-        -->
         @if(page.hasPrev){
-            <li class="page-num ikon"><a href="#" class="pg-prev" pageNum="@page.getPageIndex"><i class="ico btn-pg-prev"></i><span>PREV</span></a></li>
+            <li class="page-num ikon"><a href="@action(currentPageNum - 1)" class="pg-prev" pageNum="@(currentPageNum - 1)"><i class="ico btn-pg-prev"></i><span>PREV</span></a></li>
         } else {
             <li class="page-num ikon"><i class="ico btn-pg-prev off"></i><span class="off">PREV</span></li>
         }
 
-        @if(page.getTotalPageCount() < pageNum) {
-            @for(x <- (1 to page.getTotalPageCount())){
-                @makeLink(x + "", x)
-            }
-        } else {
-            @for( x <- (currentPageNum - pageNum/2 to currentPageNum + pageNum/2)){
-                @makeLink(x + "", x)
-            }
-        }
+        <li class="page-num"><input name="pageNum" type="number" min="1" max="@page.getTotalPageCount" class="input-mini" value="@currentPageNum"></li>
+        <li class="page-num">/</li>
+        <li class="page-num">@page.getTotalPageCount</li>
 
         @if(page.hasNext) {
-            <li class="page-num ikon"><a href="#" class="pg-prev" pageNum="@(page.getPageIndex + 2)"><span>NEXT</span><i class="ico btn-pg-next"></i></a></li>
+            <li class="page-num ikon"><a href="@action(currentPageNum + 1)" class="pg-prev" pageNum="@(currentPageNum + 1)"><span>NEXT</span><i class="ico btn-pg-next"></i></a></li>
         } else {
             <li class="page-num ikon"><span class="off">NEXT</span><i class="ico btn-pg-next off"></i></li>
         }
-        <!--
-        <li class="page-num ikon"><a href="/html/board-list.html" class="pg-latest"><i class="ico btn-pg-latest"></i></a></li>
-        -->
       </ul>
+      </form>
     </div>
-}
-@makeLink(title:String, index:Int) = {
-    @if(page.getPageIndex+1 != index){
-    <li class="page-num"><a href="#" pageNum="@index" data-page-num="@index">@title</a></li>
-    } else {
-    <li class="page-num"><span class="current">@index</span></li>
-    }
 }
app/views/search/contentsSearch.scala.html
--- app/views/search/contentsSearch.scala.html
+++ app/views/search/contentsSearch.scala.html
@@ -30,9 +30,7 @@
                 </tbody>
             </table>
         </div>
-        <div>
-            @pagination(resultIssues, 5, "issue-pagination")
-        </div>
+        <div id="pagination-issue"></div>
         }
     </div>
 </div>
@@ -62,11 +60,63 @@
             </table>
         </div>
         <div>
-            @pagination(resultPosts, 5, "post-pagination")
         </div>
         }
     </div>
+    <div id="pagination-post"></div>
 </div>
-<script type="text/javascript">nforge.require('pages.contentSearch');</script>
+<script src="@getJSLink("pagination")" type="text/javascript"></script>
+<script type="text/javascript">
+  var createUpdater = function(type, targetBody, paginationDiv) {
+    var update = function(pageNum) {
+      $.ajax({
+        url: '@routes.SearchApp.contentsSearch(project.owner, project.name)',
+        type: 'GET',
+        data: {
+          filter: '@filter', // @filter should have been escaped to avoid xss.
+          type: type
+        },
+        dataType: 'html',
+        headers: { 'Range': 'pages=' + pageNum },
+        success: function(data, status, xhr) {
+          var pattern = /(.*?)\s+(.*?)\/(.*)/;
+          var contentRange, totalPages;
+
+          contentRange = pattern.exec(xhr.getResponseHeader('Content-Range'));
+          totalPages = parseInt(contentRange[3]);
+
+          // update the list
+          $(targetBody).html(data);
+
+          // Update pagination in $(paginationDiv)
+          Pagination.update($(paginationDiv), totalPages, {
+            current: parseInt(contentRange[2]),
+            submit: update
+          });
+        }
+      });
+    }
+
+    return update;
+  }
+
+  $(document).ready(function() {
+    // Update pagination in #pagination-post.
+    Pagination.update(
+      $('#pagination-post'),
+      @resultPosts.getTotalPageCount,
+      { current: @resultPosts.getPageIndex + 1,
+        submit: createUpdater('post', $('.post-tbody'), $('#pagination-post'))}
+    );
+
+    // Update pagination in #pagination-issue.
+    Pagination.update(
+      $('#pagination-issue'),
+      @resultIssues.getTotalPageCount,
+      { current: @resultIssues.getPageIndex + 1,
+        submit: createUpdater('issue', $('.issue-tbody'), $('#pagination-issue'))}
+    );
+  });
+</script>
 </div>
-}
(No newline at end of file)
+}
conf/routes
--- conf/routes
+++ conf/routes
@@ -46,7 +46,7 @@
 POST    /files/:id                            			controllers.AttachmentApp.deleteFile(id: Long)
 
 # Boards
-GET     /:user/:project/posts                           controllers.BoardApp.posts(user, project)
+GET     /:user/:project/posts                           controllers.BoardApp.posts(user, project, pageNum: Int ?= 1)
 GET     /:user/:project/postform                        controllers.BoardApp.newPostForm(user, project)
 POST    /:user/:project/posts                           controllers.BoardApp.newPost(user, project)
 GET     /:user/:project/post/:id                        controllers.BoardApp.post(user, project, id:Long)
@@ -79,7 +79,7 @@
 GET     /:user/:project/milestone/:id/delete            controllers.MilestoneApp.deleteMilestone(user, project, id: Long)
 
 # Issues
-GET     /:user/:project/issues                          controllers.IssueApp.issues(user, project, state:String ?= "open", format:String ?= "html")
+GET     /:user/:project/issues                          controllers.IssueApp.issues(user, project, state:String ?= "open", format:String ?= "html", pageNum: Int ?= 1)
 GET     /:user/:project/issueform                       controllers.IssueApp.newIssueForm(user, project)
 POST    /:user/:project/issues                          controllers.IssueApp.newIssue(user, project)
 GET     /:user/:project/issue/:id                       controllers.IssueApp.issue(user, project, id:Long)
@@ -126,7 +126,7 @@
 GET     /:user/:project/commit/:id                      controllers.CodeHistoryApp.show(user, project, id:String)
 
 # Search
-GET     /:user/:project/search                          controllers.SearchApp.contentsSearch(user, project)
+GET     /:user/:project/search                          controllers.SearchApp.contentsSearch(user, project, page: Int ?= 0)
 
 #task
 GET     /:user/:project/task                            controllers.TaskApp.index(user, project)
 
docs/technical/pagination.md (added)
+++ docs/technical/pagination.md
@@ -0,0 +1,203 @@
+Introduction
+============
+
+게시물의 목록과 같이 컬렉션인 리소스의 경우, 그 리소스의 전체가 아닌 일부만 보고 싶은 경우가 있다. 이러한 요구를 만족시키기 위해 N4는 pagination을 지원한다. pagination을 지원하는 리소스는 페이지들의 list로 간주되며, 페이지는 1개 이상의 element의 list이다. 클라이언트는 리소스의 특정 페이지만을 요청할 수 있으며, 서버 역시 특정 페이지만을 돌려줄 수 있다.
+
+pagination.js
+=============
+
+N4는 pagination 레이아웃을 그려주는 자바스크립트 라이브러리인 pagination.js를 제공한다. 이 파일은 public/javascript/paginatin.js 에 위치한다.
+
+Pagination.update(target, totalPages, options)
+----------------------------------------------
+
+pagination을 그린다.
+
+### target
+
+Type: String, jQuery object
+
+pagination을 그릴 HTML Element. 그리기 전 이 HTML Element의 모든 children은 삭제된다.
+
+### totalPages
+
+Type: Number
+
+총 페이지 수
+
+### options
+
+Type: PlainObject
+
+#### options.url (default: document.URL)
+
+Type: String
+
+특정 페이지를 얻어오기 위한 요청을 보낼 url. options.current가 정의되지 않은 경우, 이 값 역시 이 url을 통해 얻는다.
+
+비동기 모드에서는 사용되지 않는다.
+
+#### options.paramNameForPage (default: 'pageNum')
+
+Type: String
+
+비동기 모드에서는 사용되지 않는다.
+
+#### options.current (default: options.url을 통해 얻음)
+
+Type: Number
+
+현재 페이지의 번호
+
+#### options.firstPage (default: 1)
+
+Type: Number
+
+첫번째 페이지
+
+#### options.hasPrev (default: options.current와 options.firstPage로 계산)
+
+Type: Boolean
+
+현재 페이지 기준으로, 이전 페이지가 존재하는지의 여부
+
+#### options.hasNext (default: options.current와 totalPages로 계산)
+
+Type: Boolean
+
+현재 페이지 기준으로, 다음 페이지가 존재하는지의 여부
+
+#### options.submit (default: null)
+
+Type: Function(Number pageNum)
+
+다음 페이지 혹은 이전 페이지 링크를 클릭하거나, 이동할 페이지를 입력하고 엔터를 눌렀을 때 실행될 자바스크립트 함수. pagination.js는 이 함수에게 사용자가 이동하고자 하는 페이지의 번호를 pageNum 매개변수로 넘겨준다. 예를 들어, 사용자가 다음 페이지 링크를 클릭했다면 pageNum은 현재 페이지 번호에 1을 더한 것이 된다.
+
+이 값이 설정되면, 이전/다음 페이지 링크의 href 값은 "javascript: void(0)"이 되며, 페이지 입력창에서 페이지를 임력했을 때 해당 페이지로 이동하는 기능도 동작하지 않게 된다. 즉 모든 페이지 이동 기능을 사용자가 직접 구현해야 한다.
+
+Sync
+----
+
+options.submit에 자바스크립트 함수를 설정하지 않았다면 동기로 동작한다.
+
+사용 예:
+
+    <script src="@getJSLink("pagination")" type="text/javascript"></script>
+    <script type="text/javascript">
+    var pagination = new Pagination();
+    pagination.init(function() {
+      pagination.update($('#pagination'), @page.getTotalPageCount);
+    });
+    </script>
+
+Async
+-----
+
+options.submit에 자바스크립트 함수를 설정했다면 비동기로 동작한다.
+
+이 기능의 구현을 위해 HTTP/1.1의 Range 요청 헤더와 Accept-Ranges, Content-Range 응답 헤더를 이용한다. pagination을 지원하는 리소스에 클라이언트가 GET 요청을 보낸 경우, 서버는 Accept-Ranges 헤더를 이용해 pagination을 지원함을 클라이언트에게 알려준다. 클라이언트는 Range 헤더를 이용해 특정 페이지를 요청하고, 서버는 요청받은 페이지의 내용과 함께 전체 몇 페이지 중 어떤 페이지를 반환하는지에 대한 정보를 Content-Range 헤더에 담아 응답해준다.
+
+사용 예:
+
+    <script src="@getJSLink("pagination")" type="text/javascript"></script>
+    <script type="text/javascript">
+    var pagination = new Pagination();
+
+    var createUpdater = function(type, targetBody, paginationDiv) {
+      var submit = function(pageNum) {
+        $.ajax({
+          url: '@routes.SearchApp.contentsSearch(project.owner, project.name)',
+          type: 'GET',
+          data: {
+            filter: '@filter', // @filter should have been escaped to avoid xss.
+            type: type
+          },
+          dataType: 'html',
+          headers: { 'Range': 'pages=' + pageNum },
+          success: function(data, status, xhr) {
+            var pattern = /(.*?)\s+(.*?)\/(.*)/;
+            var contentRange, totalPages;
+
+            contentRange = pattern.exec(xhr.getResponseHeader('Content-Range'));
+            totalPages = parseInt(contentRange[3]);
+
+            // update the list
+            $(targetBody).html(data);
+
+            // Update pagination in $(paginationDiv)
+            pagination.update($(paginationDiv), totalPages, {
+              current: parseInt(contentRange[2]),
+              submit: submit
+            });
+          }
+        });
+      }
+
+      return submit;
+    }
+
+    pagination.init(function() {
+      // Update pagination in #pagination-post.
+      pagination.update(
+        $('#pagination-post'),
+        @resultPosts.getTotalPageCount,
+        { current: @resultPosts.getPageIndex + 1,
+          submit: createUpdater('post', $('.post-tbody'), $('#pagination-post'))}
+      );
+    });
+    </script>
+
+다음은 각 헤더에 대한 설명이다.
+
+#### Accept-Ranges
+
+어떤 리소스가 pagination을 지원한다면, 서버는 그 사실을 알리기 위해 해당 리소스에 대한 일반적인 GET 요청에 대한 응답에 "pages" 값을 갖는 Accept-Ranges 헤더를 포함시킨다.
+
+예:
+
+    Accept-Ranges: pages
+
+#### Range
+
+클라이언트는 다음과 같은 형식의 Range 헤더를 이용해 이 리소스의 특정 페이지만을 요청할 수 있다.
+
+    Range             = pages-unit "=" page-number
+    pages-unit        = "pages"
+    page-number       = 1*DIGIT
+    DIGIT             = <any US-ASCII digit "0".."9">
+
+예:
+
+    Range: pages=1
+
+#### Content-Range
+
+서버는 Range 요청에 대해 다음과 같은 형식의 Content-Range 헤더를 통해 206 Partial Content로 응답한다. (HTTP/1.1의 bytes-range-spec과 차이가 있음에 유의하라)
+
+    Content-Range     = pages-unit SP page-number "/" complete-length
+    pages-unit        = "pages"
+    page-number       = 1*DIGIT
+    complete-length   = 1*DIGIT
+    SP                = <US-ASCII SP, space (32)>
+
+예:
+
+    Content-Range: pages 1/2
+
+서버는 상황에 따라 클라이언트가 요청한 것과 다른 페이지를 돌려줄 수도 있다. 이러한 상황에 대한 예외처리의 책임은 클라이언트에게 있다.
+
+References
+==========
+
+Fielding, R., Ed., Y. Lafon, Ed and J. Reschke, Ed., "Hypertext Transfer Protocol (HTTP/1.1): Range Requests", Internet-Draft draft-ietf-httpbis-p5-range-latest (work in progress), January 2013.
+
+ABNF
+====
+
+`*` rule
+
+    The character "*" preceding an element indicates repetition. The
+    full form is "<n>*<m>element" indicating at least <n> and at most
+    <m> occurrences of element. Default values are 0 and infinity so
+    that "*(element)" allows any number, including zero; "1*element"
+    requires at least one; and "1*2element" allows one or two.
public/javascripts/modules/issue.js
--- public/javascripts/modules/issue.js
+++ public/javascripts/modules/issue.js
@@ -333,8 +333,7 @@
 
 nforge.issue.list = function() {
   var that;
-
-  that = {
+that = {
     init: function() {
       var searchForm = $('form.form-search');
 
@@ -371,13 +370,6 @@
           searchForm.append(
             '<input type="hidden" name="milestone" value="' + milestoneId + '">');
         }
-      });
-
-      $("#pagination a").click(function(){
-        $this = $(this);
-        var targetPageNum = $this.attr("pagenum");
-        location.href = location.href.replace(/(pageNum=)(\d+)/, "pageNum=" + (targetPageNum - 1));
-        return false;
       });
     }
   }
 
public/javascripts/modules/pages.js (deleted)
--- public/javascripts/modules/pages.js
@@ -1,35 +0,0 @@
-nforge.namespace('pages');
-
-nforge.pages.contentSearch = function () {
-  var filter,
-    $searchForm,
-    issuePagination = nforge.require('Pagination', 'issue-pagination'),
-    postPagination = nforge.require('Pagination', 'post-pagination'),
-    paginationCallback = function () {
-      var _self = this,
-        $target = _self.getUpdateTarget(),
-        params = {
-          filter : filter,
-          page   : _self.getPageInfo().pageNum,
-          type   : $target.data('type')
-        };
-
-      $.get($searchForm.attr('action'), params, function (res) {
-        _self.updatePaginationBar();
-        $target.html(res);
-      });
-    };
-
-  return {
-    init : function () {
-      filter = $('.filter').val();
-      $searchForm = $('#contentsSearchForm');
-
-      issuePagination.setCallback(paginationCallback)
-        .setUpdateTarget($('.issue-tbody').data('type', 'issue'));
-
-      postPagination.setCallback(paginationCallback)
-        .setUpdateTarget($('.post-tbody').data('type', 'post'));
-    }
-  };
-};(No newline at end of file)
 
public/javascripts/pagination.js (added)
+++ public/javascripts/pagination.js
@@ -0,0 +1,172 @@
+// Render pagination in the given target HTML element.
+//
+// Usage: Pagiation.updatePagination(target, totalPages);
+//
+// For more details, see docs/technical/pagination.md
+
+(function(window, document) {
+  var getQuery = function(url) {
+     var parser = document.createElement('a');
+     parser.href = url;
+     return parser.search;
+  };
+
+  var valueFromQuery = function(key, query) {
+    var regex = new RegExp('(^|&|\\?)' + key + '=([^&]+)');
+    var result = regex.exec(query);
+
+    if (result) {
+        return result[2];
+    } else {
+        return null;
+    }
+  };
+
+  var urlWithQuery = function(url, query) {
+     var parser = document.createElement('a');
+     parser.href = url;
+     parser.search = '?' + query;
+     return parser.href;
+  };
+
+  var urlWithPageNum = function(url, pageNum, paramNameForPage) {
+    var query = getQuery(url);
+    var regex = new RegExp('(^|&|\\?)' + paramNameForPage + '=[^&]+');
+    var result = regex.exec(query);
+    query = query.replace(regex, result[1] + paramNameForPage + '=' + pageNum);
+
+    return urlWithQuery(url, query);
+  };
+
+  var validateOptions = function(options) {
+    if (!Number.isFinite(options.current)) {
+      throw new Error("options.current is not valid: " + options.current);
+    }
+  };
+
+  window.updatePagination = function(target, totalPages, options) {
+    var target = $(target);
+    var linkToPrev, linkToNext, urlToPrevPage, urlToNextPage;
+    var ul, prev, inputBox, delimiter, total, next, query;
+    var keydownOnInput;
+    var paramNameForPage;
+    var pageNumFromUrl;
+
+    if (totalPages <= 0) return;
+
+    if (options == undefined) options = {};
+    if (options.url == undefined) options.url = document.URL;
+    if (options.firstPage == undefined) options.firstPage = 1;
+
+    paramNameForPage = options.paramNameForPage || 'pageNum';
+
+    if (!Number.isFinite(options.current)) {
+        query = getQuery(options.url);
+        pageNumFromUrl = parseInt(valueFromQuery(paramNameForPage, query));
+        options.current = pageNumFromUrl || options.firstPage;
+    }
+
+    if (options.hasPrev == undefined) {
+        options.hasPrev = options.current > options.firstPage;
+    }
+
+    if (options.hasNext == undefined) {
+        options.hasNext = options.current < totalPages;
+    }
+
+    validateOptions(options);
+
+    target.html('');
+
+    target.addClass('page-navigation-wrap');
+
+    if (options.hasPrev) {
+      linkToPrev = $('<a>')
+          .append($('<i>').addClass('ico').addClass('btn-pg-prev'))
+          .append($('<span>').text('PREV'));
+
+      if (typeof(options.submit) == 'function') {
+        linkToPrev
+            .attr('href', 'javascript: void(0);')
+            .click(function(e) { options.submit(options.current - 1); });
+      } else {
+        urlToPrevPage = urlWithPageNum(options.url, options.current - 1, paramNameForPage);
+        linkToPrev.attr('href', urlToPrevPage);
+      }
+
+      prev = $('<li>')
+          .addClass('page-num')
+          .addClass('ikon')
+          .append(linkToPrev);
+    } else {
+      prev = $('<li>')
+          .addClass('page-num')
+          .addClass('ikon')
+          .append($('<i>').addClass('ico').addClass('btn-pg-prev').addClass('off'))
+          .append($('<span>').text('PREV').addClass('off'));
+    }
+
+    if (typeof(options.submit) == 'function') {
+      keydownOnInput = function(e) { options.submit($(target).val()); };
+    } else {
+      keydownOnInput = function(e) {
+        var target = e.target || e.srcElement;
+        if (e.which == 13) {
+          location.href = urlWithPageNum(options.url, $(target).val(), paramNameForPage);
+        }
+      }
+    }
+
+    inputBox = $('<li>').addClass('page-num')
+        .append($('<input>')
+                .attr('name', 'pageNum')
+                .attr('type', 'number')
+                .attr('min', '1')
+                .attr('max', totalPages)
+                .addClass('input-mini')
+                .val(options.current)
+                .keydown(keydownOnInput));
+    delimiter = $('<li>').addClass('page-num').text('/');
+    total = $('<li>').addClass('page-num').text(totalPages);
+
+    if (options.hasNext) {
+      linkToNext = $('<a>')
+          .append($('<span>').text('NEXT'))
+          .append($('<i>').addClass('ico').addClass('btn-pg-next'));
+
+      if (typeof(options.submit) == 'function') {
+        linkToNext
+            .attr('href', 'javascript: void(0);')
+            .click(function(e) { options.submit(options.current + 1); });
+      } else {
+        urlToNextPage = urlWithPageNum(options.url, options.current + 1, paramNameForPage);
+        linkToNext.attr('href', urlToNextPage);
+      }
+
+      next = $('<li>')
+          .addClass('page-num')
+          .addClass('ikon')
+          .append(linkToNext);
+    } else {
+      next = $('<li>')
+          .addClass('page-num')
+          .addClass('ikon')
+          .append($('<i>').addClass('ico').addClass('btn-pg-next').addClass('off'))
+          .append($('<span>').text('NEXT').addClass('off'));
+    }
+
+    ul = $('<ul>')
+        .addClass('page-nums')
+        .append(prev)
+        .append(inputBox)
+        .append(delimiter)
+        .append(total)
+        .append(next);
+
+    target.append(ul);
+  };
+
+  window.Pagination = {
+    update: updatePagination
+  }
+})(window, document);
Add a comment
List