diff --git a/data/config.ini b/data/config.ini index 3359b8db..7eaf1174 100644 --- a/data/config.ini +++ b/data/config.ini @@ -10,3 +10,4 @@ minPasswordLength = 5 [users] minUserNameLength = 1 maxUserNameLength = 32 +usersPerPage = 20 diff --git a/public_html/css/core.css b/public_html/css/core.css index 21c92654..0b07536f 100644 --- a/public_html/css/core.css +++ b/public_html/css/core.css @@ -23,3 +23,7 @@ a { color: #6a2; text-decoration: none; } + +a:hover { + color: #7b3; +} diff --git a/public_html/css/pager.css b/public_html/css/pager.css new file mode 100644 index 00000000..e1e3177b --- /dev/null +++ b/public_html/css/pager.css @@ -0,0 +1,27 @@ +.pager { + text-align: center; + margin-top: 1em; +} + +.pager ul { + list-style-type: none; + padding: 0; + margin: 0 auto; + display: inline-block; +} + +.pager li { + display: inline-block; +} + +.pager li a { + display: inline-block; + padding: 0.4em 1.2em; +} + +.pager li:hover a { + background: #efa; +} +.pager li.active a { + background: #faffca; +} diff --git a/public_html/css/user-list.css b/public_html/css/user-list.css new file mode 100644 index 00000000..2bf223aa --- /dev/null +++ b/public_html/css/user-list.css @@ -0,0 +1,27 @@ +#user-list { + min-width: 20em; +} + +#user-list ul { + list-style-type: none; + padding: 0; + margin: 0; +} + +#user-list ul.order { + margin: -0.5em -0.5em 0.5em -0.5em; +} +#user-list ul.order li { + display: inline-block; + margin: 0 0.5em; +} + +#user-list ul.order li.active a { + background: #faffca; +} + +#user-list ul.order a { + cursor: pointer; + display: inline-block; + padding: 0.2em 0.5em; +} diff --git a/public_html/index.html b/public_html/index.html index 51920f55..c3e85446 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -12,8 +12,10 @@ + + @@ -31,6 +33,7 @@ + diff --git a/public_html/js/Presenters/PagedCollectionPresenter.js b/public_html/js/Presenters/PagedCollectionPresenter.js new file mode 100644 index 00000000..b54897e2 --- /dev/null +++ b/public_html/js/Presenters/PagedCollectionPresenter.js @@ -0,0 +1,114 @@ +var App = App || {}; +App.Presenters = App.Presenters || {}; + +App.Presenters.PagedCollectionPresenter = function(api, util) { + + var searchOrder; + var searchQuery; + var pageNumber; + var baseUri; + var backendUri; + var renderCallback; + + var template; + var pageSize; + var totalRecords; + + function init(args) { + parseSearchArgs(args.searchArgs); + baseUri = args.baseUri; + backendUri = args.backendUri; + renderCallback = args.renderCallback; + + util.loadTemplate('pager').then(function(html) { + template = _.template(html); + //renderCallback({entities: [], totalRecords: 0}); + + changePage(pageNumber); + }); + } + + function changePage(newPageNumber) { + pageNumber = newPageNumber; + + api.get(backendUri, { + order: searchOrder, + query: searchQuery, + page: pageNumber + }).then(function(response) { + totalRecords = response.json.totalRecords; + pageSize = response.json.pageSize; + renderCallback({ + entities: response.json.data, + totalRecords: response.json.totalRecords}); + }).catch(function(response) { + console.log(Error(response.json && response.json.error || response)); + }); + } + + function render($target) { + var totalPages = Math.ceil(totalRecords / pageSize); + var pages = [1, totalPages]; + var pagesAroundCurrent = 2; + for (var i = -pagesAroundCurrent; i <= pagesAroundCurrent; i ++) + if (pageNumber + i >= 1 && pageNumber + i <= totalPages) + pages.push(pageNumber + i); + if (pageNumber - pagesAroundCurrent - 1 == 2) + pages.push(2); + if (pageNumber + pagesAroundCurrent + 1 == totalPages - 1) + pages.push(totalPages - 1); + + pages = pages.sort(function(a, b) { return a - b; }).filter(function(item, pos) { + return !pos || item != pages[pos - 1]; + }); + + $target.html(template({ + pages: pages, + pageNumber: pageNumber, + link: getPageChangeLink, + })); + } + + function getSearchQueryChangeLink(newSearchQuery) { + return util.compileComplexRouteArgs(baseUri, { + page: 1, + order: searchOrder, + query: newSearchQuery, + }); + } + + function getSearchOrderChangeLink(newSearchOrder) { + return util.compileComplexRouteArgs(baseUri, { + page: 1, + order: newSearchOrder, + query: searchQuery, + }); + } + + function getPageChangeLink(newPageNumber) { + return util.compileComplexRouteArgs(baseUri, { + page: newPageNumber, + order: searchOrder, + query: searchQuery, + }); + } + + function parseSearchArgs(searchArgs) { + var args = util.parseComplexRouteArgs(searchArgs); + pageNumber = parseInt(args.page) || 1; + searchOrder = args.order; + searchQuery = args.query; + } + + return { + init: init, + render: render, + changePage: changePage, + getSearchQueryChangeLink: getSearchQueryChangeLink, + getSearchOrderChangeLink: getSearchOrderChangeLink, + getPageChangeLink: getPageChangeLink + }; + +}; + +App.DI.register('pagedCollectionPresenter', App.Presenters.PagedCollectionPresenter); diff --git a/public_html/js/Presenters/UserListPresenter.js b/public_html/js/Presenters/UserListPresenter.js index 77b40009..f2b77c77 100644 --- a/public_html/js/Presenters/UserListPresenter.js +++ b/public_html/js/Presenters/UserListPresenter.js @@ -1,19 +1,56 @@ var App = App || {}; App.Presenters = App.Presenters || {}; -App.Presenters.UserListPresenter = function(jQuery, topNavigationPresenter, appState) { +App.Presenters.UserListPresenter = function( + jQuery, + util, + router, + pagedCollectionPresenter, + topNavigationPresenter) { var $el = jQuery('#content'); + var template; + var userList = []; + var activeSearchOrder; - function init() { + function init(args) { topNavigationPresenter.select('users'); - render(); + activeSearchOrder = util.parseComplexRouteArgs(args.searchArgs).order; + + util.loadTemplate('user-list').then(function(html) { + template = _.template(html); + + pagedCollectionPresenter.init({ + searchArgs: args.searchArgs, + baseUri: '#/users', + backendUri: '/users', + renderCallback: function updateCollection(data) { + userList = data.entities; + render(); + }}); + }); } function render() { - $el.html('Logged in: ' + appState.get('loggedIn')); + $el.html(template({ + userList: userList, + })); + $el.find('.order a').click(orderLinkClicked); + $el.find('.order [data-order="' + activeSearchOrder + '"]').parent('li').addClass('active'); + console.log(activeSearchOrder); + + var $pager = $el.find('.pager'); + pagedCollectionPresenter.render($pager); }; + function orderLinkClicked(e) + { + e.preventDefault(); + var $orderLink = jQuery(this); + activeSearchOrder = $orderLink.attr('data-order'); + router.navigate(pagedCollectionPresenter.getSearchOrderChangeLink(activeSearchOrder)); + } + return { init: init, render: render diff --git a/public_html/js/Router.js b/public_html/js/Router.js index 7755ff0e..fafea7e6 100644 --- a/public_html/js/Router.js +++ b/public_html/js/Router.js @@ -23,7 +23,8 @@ App.Router = function(jQuery, util) { inject('#/logout', function() { return App.DI.get('logoutPresenter'); }); inject('#/register', function() { return App.DI.get('registrationPresenter'); }); inject('#/users', function() { return App.DI.get('userListPresenter'); }); - inject('#/users/:userName', function() { return App.DI.get('userPresenter'); }); + inject('#/users/:searchArgs', function() { return App.DI.get('userListPresenter'); }); + inject('#/user/:userName', function() { return App.DI.get('userPresenter'); }); setRoot('#/users'); }; diff --git a/public_html/js/Util.js b/public_html/js/Util.js index 44e03e8a..843fcd63 100644 --- a/public_html/js/Util.js +++ b/public_html/js/Util.js @@ -4,6 +4,30 @@ App.Util = (function(jQuery) { var templateCache = {}; + function parseComplexRouteArgs(args) { + var result = {}; + args = (args || '').split(/;/); + for (var i = 0; i < args.length; i ++) { + var arg = args[i]; + if (!arg) + continue; + kv = arg.split(/=/); + result[kv[0]] = kv[1]; + } + return result; + } + + function compileComplexRouteArgs(baseUri, args) { + var result = baseUri + '/'; + _.each(args, function(v, k) { + if (typeof(v) == 'undefined') + return; + result += k + '=' + v + ';' + }); + result = result.slice(0, -1); + return result; + } + function loadTemplate(templateName) { return loadTemplateFromCache(templateName) || loadTemplateFromDOM(templateName) @@ -62,6 +86,8 @@ App.Util = (function(jQuery) { loadTemplate: loadTemplate, initPresenter : initPresenter, initContentPresenter: initContentPresenter, + parseComplexRouteArgs: parseComplexRouteArgs, + compileComplexRouteArgs: compileComplexRouteArgs, }; }); diff --git a/public_html/templates/pager.tpl b/public_html/templates/pager.tpl new file mode 100644 index 00000000..96485413 --- /dev/null +++ b/public_html/templates/pager.tpl @@ -0,0 +1,15 @@ + diff --git a/public_html/templates/top-navigation.tpl b/public_html/templates/top-navigation.tpl index db4ec709..4f14628d 100644 --- a/public_html/templates/top-navigation.tpl +++ b/public_html/templates/top-navigation.tpl @@ -12,7 +12,7 @@ <% } else { %>
  • - <%= user.name %> + <%= user.name %>
  • Logout diff --git a/public_html/templates/user-list.tpl b/public_html/templates/user-list.tpl new file mode 100644 index 00000000..f009f657 --- /dev/null +++ b/public_html/templates/user-list.tpl @@ -0,0 +1,24 @@ +
    + + + <% _.each(userList, function(user) { %> +
    + User name: <%= user.name %> +
    + <% }); %> + +
    +
    diff --git a/src/Config.php b/src/Config.php index ac678aff..051db826 100644 --- a/src/Config.php +++ b/src/Config.php @@ -1,7 +1,7 @@ post('/api/users', [$this, 'register']); - $router->get('/api/users', [$this, 'getAll']); + $router->get('/api/users', [$this, 'getFiltered']); $router->get('/api/users/:id', [$this, 'getById']); $router->put('/api/users/:id', [$this, 'update']); $router->delete('/api/users/:id', [$this, 'delete']); } + public function getFiltered() + { + //todo: move this to form data constructor + $searchFormData = new \Szurubooru\FormData\SearchFormData; + $searchFormData->query = $this->inputReader->query; + $searchFormData->order = $this->inputReader->order; + $searchFormData->pageNumber = $this->inputReader->page; + $searchResult = $this->userService->getFiltered($searchFormData); + $entities = array_map(function($user) { return new \Szurubooru\ViewProxies\User($user); }, $searchResult->entities); + return [ + 'data' => $entities, + 'pageSize' => $searchResult->filter->pageSize, + 'totalRecords' => $searchResult->totalRecords]; + } + + public function getById($id) + { + throw new \BadMethodCallException('Not implemented'); + } + public function register() { $input = new \Szurubooru\FormData\RegistrationFormData; + //todo: move this to form data constructor $input->name = $this->inputReader->userName; $input->password = $this->inputReader->password; $input->email = $this->inputReader->email; @@ -40,16 +61,6 @@ final class UserController extends AbstractController throw new \BadMethodCallException('Not implemented'); } - public function getAll() - { - throw new \BadMethodCallException('Not implemented'); - } - - public function getById($id) - { - throw new \BadMethodCallException('Not implemented'); - } - public function delete($id) { throw new \BadMethodCallException('Not implemented'); diff --git a/src/Dao/AbstractDao.php b/src/Dao/AbstractDao.php index 06fe0e19..ecd14385 100644 --- a/src/Dao/AbstractDao.php +++ b/src/Dao/AbstractDao.php @@ -19,6 +19,16 @@ abstract class AbstractDao implements ICrudDao $this->entityName = $entityName; } + public function getCollection() + { + return $this->collection; + } + + public function getEntityConverter() + { + return $this->entityConverter; + } + public function save(&$entity) { $arrayEntity = $this->entityConverter->toArray($entity); diff --git a/src/Dao/SearchFilter.php b/src/Dao/SearchFilter.php new file mode 100644 index 00000000..5788f4b8 --- /dev/null +++ b/src/Dao/SearchFilter.php @@ -0,0 +1,21 @@ +pageSize = $pageSize; + if ($searchFormData) + { + $this->query = $searchFormData->query; + $this->order = $searchFormData->order; + $this->pageNumber = $searchFormData->pageNumber; + } + } +} diff --git a/src/Dao/SearchResult.php b/src/Dao/SearchResult.php new file mode 100644 index 00000000..24e1582a --- /dev/null +++ b/src/Dao/SearchResult.php @@ -0,0 +1,17 @@ +filter = $filter; + $this->entities = $entities; + $this->totalRecords = $totalRecords; + } +} + diff --git a/src/Dao/Services/AbstractSearchService.php b/src/Dao/Services/AbstractSearchService.php new file mode 100644 index 00000000..6e523eb7 --- /dev/null +++ b/src/Dao/Services/AbstractSearchService.php @@ -0,0 +1,122 @@ +collection = $dao->getCollection(); + $this->entityConverter = $dao->getEntityConverter(); + } + + public function getFiltered( + \Szurubooru\Dao\SearchFilter $searchFilter) + { + list ($basicTokens, $complexTokens) = $this->tokenize($searchFilter->query); + + $filter = []; + if ($basicTokens) + $this->decorateFilterWithBasicTokens($filter, $basicTokens); + if ($complexTokens) + $this->decorateFilterWithComplexTokens($filter, $complexTokens); + + $order = $this->getOrder($searchFilter->order); + $pageSize = min(100, max(1, $searchFilter->pageSize)); + $pageNumber = max(1, $searchFilter->pageNumber) - 1; + + $cursor = $this->collection->find($filter); + $totalRecords = $cursor->count(); + $cursor->sort($order); + $cursor->skip($pageSize * $pageNumber); + $cursor->limit($pageSize); + + $entities = []; + foreach ($cursor as $arrayEntity) + $entities []= $this->entityConverter->toEntity($arrayEntity); + + return new \Szurubooru\Dao\SearchResult($searchFilter, $entities, $totalRecords); + } + + protected function decorateFilterWithBasicTokens(&$filter, $basicTokens) + { + throw new \BadMethodCallException('Not supported'); + } + + protected function decorateFilterWithComplexTokens(&$filter, $complexTokens) + { + throw new \BadMethodCallException('Not supported'); + } + + protected function getOrderColumn($token) + { + throw new \BadMethodCallException('Not supported'); + } + + protected function getDefaultOrderColumn() + { + return '_id'; + } + + protected function getDefaultOrderDir() + { + return self::ORDER_DESC; + } + + private function getOrder($query) + { + $order = []; + $tokens = array_filter(preg_split('/\s+/', $query)); + foreach ($tokens as $token) + { + $token = preg_split('/,|\s+/', $token); + if (count($token) == 2) + { + $orderDir = $token[1] == 'desc' ? self::ORDER_DESC : self::ORDER_ASC; + $orderToken = $token[0]; + } + else + { + $orderDir = self::ORDER_ASC; + $orderToken = $token; + } + $orderColumn = $this->getOrderColumn($token[0]); + if ($orderColumn === null) + throw new \InvalidArgumentException('Invalid search order token: ' . $token); + $order[$orderColumn] = $orderDir; + } + $defaultOrderColumn = $this->getDefaultOrderColumn(); + $defaultOrderDir = $this->getDefaultOrderDir(); + if ($defaultOrderColumn) + $order[$defaultOrderColumn] = $defaultOrderDir; + return $order; + } + + private function tokenize($query) + { + $basicTokens = []; + $complexTokens = []; + + $tokens = array_filter(preg_split('/\s+/', $query)); + foreach ($tokens as $token) + { + if (strpos($token, ':') !== false) + { + list ($key, $value) = explode(':', $token, 1); + $complexTokens[$key] = $value; + } + else + { + $basicTokens []= $token; + } + } + + return [$basicTokens, $complexTokens]; + } +} diff --git a/src/Dao/Services/UserSearchService.php b/src/Dao/Services/UserSearchService.php new file mode 100644 index 00000000..f52dfda5 --- /dev/null +++ b/src/Dao/Services/UserSearchService.php @@ -0,0 +1,31 @@ +config = $config; $this->validator = $validator; $this->userDao = $userDao; + $this->userSearchService = $userSearchService; $this->passwordService = $passwordService; $this->emailService = $emailService; $this->timeService = $timeService; } + public function getFiltered(\Szurubooru\FormData\SearchFormData $formData) + { + $pageSize = intval($this->config->users->usersPerPage); + $this->validator->validateNumber($formData->page); + $searchFilter = new \Szurubooru\Dao\SearchFilter($pageSize, $formData); + return $this->userSearchService->getFiltered($searchFilter); + } + public function register(\Szurubooru\FormData\RegistrationFormData $formData) { $this->validator->validateUserName($formData->name); diff --git a/src/Validator.php b/src/Validator.php index 32dc006b..dda0248c 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -10,6 +10,10 @@ class Validator $this->config = $config; } + public function validateNumber(&$subject) { + $subject = intval($subject); + } + public function validateNonEmpty($subject, $subjectName = 'Object') { if (!$subject) diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php index da069619..98c5e5eb 100644 --- a/tests/AbstractTestCase.php +++ b/tests/AbstractTestCase.php @@ -8,3 +8,5 @@ abstract class AbstractTestCase extends \PHPUnit_Framework_TestCase return $this->getMockBuilder($className)->disableOriginalConstructor()->getMock(); } } + +date_default_timezone_set('UTC'); diff --git a/tests/Dao/Services/UserSearchServiceTest.php b/tests/Dao/Services/UserSearchServiceTest.php new file mode 100644 index 00000000..d634a67b --- /dev/null +++ b/tests/Dao/Services/UserSearchServiceTest.php @@ -0,0 +1,67 @@ +userDao = new \Szurubooru\Dao\UserDao($this->databaseConnection); + } + + public function testNothing() + { + $searchFilter = new \Szurubooru\Dao\SearchFilter(1); + $expected = new \Szurubooru\Dao\SearchResult($searchFilter, [], 0); + + $userSearchService = $this->getUserSearchService(); + $actual = $userSearchService->getFiltered($searchFilter); + $this->assertEquals($expected, $actual); + } + + public function testSorting() + { + $user1 = new \Szurubooru\Entities\User(); + $user1->name = 'reginald'; + $user1->registrationTime = date('c', mktime(3, 2, 1)); + $user2 = new \Szurubooru\Entities\User(); + $user2->name = 'beartato'; + $user2->registrationTime = date('c', mktime(1, 2, 3)); + + $this->userDao->save($user1); + $this->userDao->save($user2); + + $userSearchService = $this->getUserSearchService(); + $searchFilter = new \Szurubooru\Dao\SearchFilter(1); + $expected = new \Szurubooru\Dao\SearchResult($searchFilter, [$user2], 2); + $actual = $userSearchService->getFiltered($searchFilter); + $this->assertEquals($expected, $actual); + + $searchFilter->order = 'name,asc'; + $expected = new \Szurubooru\Dao\SearchResult($searchFilter, [$user2], 2); + $actual = $userSearchService->getFiltered($searchFilter); + $this->assertEquals($expected, $actual); + + $searchFilter->order = 'name,desc'; + $expected = new \Szurubooru\Dao\SearchResult($searchFilter, [$user1], 2); + $actual = $userSearchService->getFiltered($searchFilter); + $this->assertEquals($expected, $actual); + + $searchFilter->order = 'registrationTime,desc'; + $expected = new \Szurubooru\Dao\SearchResult($searchFilter, [$user1], 2); + $actual = $userSearchService->getFiltered($searchFilter); + $this->assertEquals($expected, $actual); + + $searchFilter->order = 'registrationTime'; + $expected = new \Szurubooru\Dao\SearchResult($searchFilter, [$user2], 2); + $actual = $userSearchService->getFiltered($searchFilter); + $this->assertEquals($expected, $actual); + } + + private function getUserSearchService() + { + return new \Szurubooru\Dao\Services\UserSearchService($this->userDao); + } +} diff --git a/tests/Dao/UserDaoTest.php b/tests/Dao/UserDaoTest.php index b0b0ac11..aeb6033b 100644 --- a/tests/Dao/UserDaoTest.php +++ b/tests/Dao/UserDaoTest.php @@ -5,7 +5,7 @@ final class UserDaoTest extends \Szurubooru\Tests\AbstractDatabaseTestCase { public function testRetrievingByValidName() { - $userDao = new \Szurubooru\Dao\UserDao($this->databaseConnection); + $userDao = $this->getUserDao(); $user = new \Szurubooru\Entities\User(); $user->name = 'test'; @@ -19,7 +19,7 @@ final class UserDaoTest extends \Szurubooru\Tests\AbstractDatabaseTestCase public function testRetrievingByInvalidName() { - $userDao = new \Szurubooru\Dao\UserDao($this->databaseConnection); + $userDao = $this->getUserDao(); $actual = $userDao->getByName('rubbish'); @@ -28,7 +28,7 @@ final class UserDaoTest extends \Szurubooru\Tests\AbstractDatabaseTestCase public function testCheckingUserPresence() { - $userDao = new \Szurubooru\Dao\UserDao($this->databaseConnection); + $userDao = $this->getUserDao(); $this->assertFalse($userDao->hasAnyUsers()); @@ -38,4 +38,9 @@ final class UserDaoTest extends \Szurubooru\Tests\AbstractDatabaseTestCase $this->assertTrue($userDao->hasAnyUsers()); } + + private function getUserDao() + { + return new \Szurubooru\Dao\UserDao($this->databaseConnection); + } } diff --git a/tests/Services/UserServiceTest.php b/tests/Services/UserServiceTest.php index 025a36a3..a421a3dc 100644 --- a/tests/Services/UserServiceTest.php +++ b/tests/Services/UserServiceTest.php @@ -3,21 +3,44 @@ namespace Szurubooru\Tests\Services; final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase { + private $configMock; private $validatorMock; private $userDaoMock; + private $userSearchServiceMock; private $passwordServiceMock; private $emailServiceMock; private $timeServiceMock; public function setUp() { + $this->configMock = $this->mock(\Szurubooru\Config::class); $this->validatorMock = $this->mock(\Szurubooru\Validator::class); $this->userDaoMock = $this->mock(\Szurubooru\Dao\UserDao::class); + $this->userSearchService = $this->mock(\Szurubooru\Dao\Services\UserSearchService::class); $this->passwordServiceMock = $this->mock(\Szurubooru\Services\PasswordService::class); $this->emailServiceMock = $this->mock(\Szurubooru\Services\EmailService::class); $this->timeServiceMock = $this->mock(\Szurubooru\Services\TimeService::class); } + public function testGettingFilteredUsers() + { + $mockUser = new \Szurubooru\Entities\User(); + $mockUser->name = 'user'; + $expected = [$mockUser]; + $this->userSearchService->method('getFiltered')->willReturn($expected); + + $this->configMock->users = new \StdClass; + $this->configMock->users->usersPerPage = 1; + $searchFormData = new \Szurubooru\FormData\SearchFormData; + $searchFormData->query = ''; + $searchFormData->order = 'joined'; + $searchFormData->page = 2; + + $userService = $this->getUserService(); + $actual = $userService->getFiltered($searchFormData); + $this->assertEquals($expected, $actual); + } + public function testValidRegistration() { $formData = new \Szurubooru\FormData\RegistrationFormData; @@ -76,8 +99,10 @@ final class UserServiceTest extends \Szurubooru\Tests\AbstractTestCase private function getUserService() { return new \Szurubooru\Services\UserService( + $this->configMock, $this->validatorMock, $this->userDaoMock, + $this->userSearchService, $this->passwordServiceMock, $this->emailServiceMock, $this->timeServiceMock);