diff --git a/.eslintrc.json b/.eslintrc.json index d1642b74..404e2ab4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,7 +13,8 @@ "rules": { "semi": ["error", "always"], "quotes": ["error", "single"], - "indent": ["error", 4], + "quote-props": ["error", "consistent-as-needed"], + "indent": ["error", 4, { "CallExpression": {"arguments": "first"}, "ObjectExpression": "first" }], "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], "no-duplicate-imports": "error", "linebreak-style": "off", diff --git a/src/controllers/BaseController.js b/src/controllers/BaseController.js index d9cbe0c4..52917fc1 100644 --- a/src/controllers/BaseController.js +++ b/src/controllers/BaseController.js @@ -7,9 +7,17 @@ export default class ControllerInterface { /** * Данный метод должен реализовывать логику контроллера (получение данных и отрисовка view) в * подклассе. - * @param {URLData} data данные полученные при обработке URL'a + * @param {Object} data данные полученные при обработке URL'a */ work(data) { throw new Error('Controller: метод work должен быть реализован в подклассе'); } + + /** + * Метод вызывается роутером, при очередном срабатывании go, + * если данный контроллер был последним контроллером, активированным роутером. + */ + onDeactivating() { + throw new Error('Controller: метод onDeactivating должен быть реализован в подклассе'); + } } diff --git a/src/controllers/BoardsController/BoardsController.js b/src/controllers/BoardsController/BoardsController.js index f670d879..ce20b901 100644 --- a/src/controllers/BoardsController/BoardsController.js +++ b/src/controllers/BoardsController/BoardsController.js @@ -33,6 +33,6 @@ export default class BoardsController extends ControllerInterface { if (result === HttpStatusCodes.Ok) { return this.page.render(boards); } - router.toUrl(Urls.Login); + router.go(Urls.Login); } } diff --git a/src/controllers/LogoutController/LogoutController.js b/src/controllers/LogoutController/LogoutController.js index 8df03221..57fa0cab 100644 --- a/src/controllers/LogoutController/LogoutController.js +++ b/src/controllers/LogoutController/LogoutController.js @@ -26,7 +26,7 @@ export default class LogoutController extends ControllerInterface { if (result === HttpStatusCodes.Ok) { userStatus.setAuthorized(false); userStatus.setUserName(null); - router.toUrl(Urls.Login); + router.go(Urls.Login); return; } // TODO - красивый показ ошибок diff --git a/src/index.js b/src/index.js index 2d56cba4..03492666 100644 --- a/src/index.js +++ b/src/index.js @@ -23,17 +23,17 @@ window.addEventListener('DOMContentLoaded', async () => { await userStatus.init(); } - /* Регистрация контроллеров для роутера */ - router.registerUrl(Urls.Root, new RegisterController(root)); // placeholder - router.registerUrl(Urls.Register, new RegisterController(root)); - router.registerUrl(Urls.Logout, new LogoutController()); - router.registerUrl(Urls.Login, new LoginController(root)); - router.registerUrl(Urls.Boards, new BoardsController(root)); - try { - router.route(); + /* Регистрация контроллеров для роутера */ + router.register(Urls.Root, new RegisterController(root)); // placeholder + router.register(Urls.Register, new RegisterController(root)); + router.register(Urls.Logout, new LogoutController()); + router.register(Urls.Login, new LoginController(root)); + router.register(Urls.Boards, new BoardsController(root)); + + router.start(); } catch (error) { - // TODO - красивый вывод + // TODO - красивый вывод console.error(error); } }); diff --git a/src/pages/BoardsPage/BoardsPage.js b/src/pages/BoardsPage/BoardsPage.js index 792a71d3..1b273a7b 100644 --- a/src/pages/BoardsPage/BoardsPage.js +++ b/src/pages/BoardsPage/BoardsPage.js @@ -31,7 +31,7 @@ export default class BoardsPage extends BasePage { render(context) { /* Если пользователь не авторизован, то перебросить его на вход */ if (!userStatus.getAuthorized()) { - router.toUrl(Urls.Login); + router.go(Urls.Login); } const data = this.prepareBoards(context); diff --git a/src/pages/LoginPage/LoginPage.js b/src/pages/LoginPage/LoginPage.js index 9614845f..2d1b42b0 100644 --- a/src/pages/LoginPage/LoginPage.js +++ b/src/pages/LoginPage/LoginPage.js @@ -34,7 +34,7 @@ export default class LoginPage extends BasePage { render(context) { /* Если пользователь авторизован, то перебросить его на страницу списка досок */ if (userStatus.getAuthorized()) { - router.toUrl(Urls.Boards); + router.go(Urls.Boards); } super.render(context); @@ -163,7 +163,7 @@ export default class LoginPage extends BasePage { if (result === HttpStatusCodes.Ok) { userStatus.setAuthorized(true); userStatus.setUserName(data.login); - router.toUrl(Urls.Boards); + router.go(Urls.Boards); return; } diff --git a/src/pages/RegisterPage/RegisterPage.js b/src/pages/RegisterPage/RegisterPage.js index 84114dff..7f571524 100644 --- a/src/pages/RegisterPage/RegisterPage.js +++ b/src/pages/RegisterPage/RegisterPage.js @@ -34,7 +34,7 @@ export default class RegisterPage extends BasePage { render(context) { /* Если пользователь авторизован, то перебросить его на страницу списка досок */ if (userStatus.getAuthorized()) { - router.toUrl(Urls.Boards); + router.go(Urls.Boards); return; } @@ -174,7 +174,7 @@ export default class RegisterPage extends BasePage { userStatus.setAuthorized(true); userStatus.setUserName(data.login); this.removeEventListeners(); - router.toUrl(Urls.Boards); + router.go(Urls.Boards); return; } diff --git a/src/utils/Router/Router.js b/src/utils/Router/Router.js index 44cb14e3..252e0295 100644 --- a/src/utils/Router/Router.js +++ b/src/utils/Router/Router.js @@ -2,42 +2,6 @@ import ControllerInterface from '../../controllers/BaseController.js'; import NotFoundController from '../../controllers/NotFound/NotFoundController.js'; import {Html, Urls} from '../constants.js'; -/** - * Класс URLData, хранящий URL - */ -export class URLData { - /** - * Конструктор класса URLData. - */ - constructor() { - this.url = ''; - this.pathParams = {}; - this.getParams = {}; - } - - /** - * Парсит переданный url: первое значение за '/' считается url (url), - * остальные значения за '/' - параметры url'a (urlParams), - * также парсит get параметры (getParams) - * @param {string} url на разбор - * @return {URLData} коллекция распаршенных данных - */ - static fromURL(url) { - if (url === null) { - throw new Error('URLData: передан пустой url'); - } - - const urlObject = new URL(url, origin); - const data = new URLData(); - - /* Path всегда имеет один "/" в начале и ни одного в конце: */ - data.url = `/${urlObject.pathname.replace(/^(\/)+|(\/)+$/g, '')}`; - data.getParams = Object.fromEntries(urlObject.searchParams); - - return data; - } -} - /** * Роутер отсеживает переход по url, и вызывает соответствующие им контроллеры */ @@ -46,79 +10,93 @@ class Router { * Конструирует роутер. */ constructor() { - this.root = document.getElementById(Html.Root); - if (this.root == null) { + this._root = document.getElementById(Html.Root); + if (!this._root) { throw new Error(`Router: не найден корневой элемент с id ${Html.Root}`); } - this.routes = new Map(); + this._routes = {}; + this._currentController = undefined; this.registerNotFound(); } /** - * Регистрация URL'a и соответствующего ему контроллера. - * @param {string} url - url + * Регистрация шаблона URL. Шаблон может содержать path. переменные. + * Синтаксис строки шаблона url описан в модуле URLProcessor в классе URLTemplateValidator. + * @param {string} template - шаблон url'a * @param {ControllerInterface} controller - контроллер url - * @return {Router} cсылку на this + * @return {Router} - ссылку на объект роутера */ - registerUrl(url, controller) { - if ((url.match(/\//g) || []).length !== 1 || url[0] !== '/') { - throw new Error('Router: регестрируемый url должен соотв. шаблону "/path_name"'); + register(template, controller) { + if (!this.isTemplateValid(template)) { + return this; } if (!(controller instanceof ControllerInterface)) { - throw new Error('Router: контроллер должен реализовывать ControllerInterface'); + return this; } - this.routes.set(url, controller); + this._routes[template] = controller; return this; } /** * Инициализирует роутер: устанавливает обработчики событий, обрабатывает текущий url. */ - route() { - this.root.addEventListener('click', (event) => { - const {target} = event; - if (target instanceof HTMLAnchorElement) { + start() { + this._root.addEventListener('click', (event) => { + const link = event.target.closest('a'); + if (link instanceof HTMLAnchorElement) { event.preventDefault(); - this.toUrl(target.pathname); + this.go(link.pathname + link.search); } }); window.addEventListener('popstate', (event) => { - event.preventDefault(); - this.toUrl(location.pathname); + this.go(window.location.pathname + window.location.search); }); - this.toUrl(location.pathname); + this.go(window.location.pathname + window.location.search); } /** - * Переход по URL. - * @param {string} url + * Выполняет переход по относительному url. + * Относительный url - это часть url которая следует за именем хоста. + * Пример: в URL "http://a.com/b/c?key=val" относительная часть - "/b/c?key=val" + * @param {string} url - url на который следует перейти */ - toUrl(url) { - if (location.pathname !== url) { - history.pushState(null, null, url); - } - - const data = URLData.fromURL(url); - const controller = this.routes.get(data.url); - - if (controller === undefined) { - console.log(`Router: не найден контроллер для url'a "${data.url}"`); - this.toUrl(Urls.NotFound); + go(url) { + const {urlData, controller} = this.processURL(url) || {}; + if (!urlData || !controller) { + this.go(Urls.NotFound); return; } - controller.work(data); + if (this._currentController) { + this._currentController.onDeactivating(); + } + this._currentController = controller; + controller.work(urlData); + + /** + * Отсекаем добавление записей в историю для случаев: + * - Второй раз подряд нажимаем одну и туже ссылку (переходим по URL) + * - Сработало событие popstate, и в истоии уже есть актуальная запись + * (иначе же будет добавлена ее копия, а следующий back приведет на текущий URl и т.д.) + */ + if (window.location.pathname + window.location.search !== url) { + /** + * Добавляет запись в историю и делает ее активной. + * Не приводит к срабатыванию popstate. + */ + window.history.pushState(null, null, url); + } } /** * Возврат на предыдущий URL в истории */ - toPrev() { + prev() { window.history.back(); } @@ -130,10 +108,111 @@ class Router { } /** - * Регестрирует контроллер по умолчанию + * Регестрирует контроллер по умолчанию для неизвестных url */ registerNotFound() { - this.registerUrl(Urls.NotFound, new NotFoundController(document.getElementById(Html.Root))); + if (!this.isTemplateValid(Urls.NotFound)) { + throw new Error(`Шаблон ${Urls.NotFound} для NotFoundController не валидный`); + } + this.register(Urls.NotFound, new NotFoundController(document.getElementById(Html.Root))); + } + + /** + * Проверяет соответствие шаблона минимуму критериев + * @param {string} template - проверяемый шаблон + * @return {boolean} результат проверки + */ + isTemplateValid(template) { + /* Все url стартуют с "/" */ + return !(!template.startsWith('/') || + /* Не должно быть пустых имен параметров */ + (template.search('/<>/') !== -1 || template.search('/<>') !== -1) || + /* Шаблон url не заканчивается на "/" */ + (template !== '/' && template.endsWith('/'))); + } + + /** + * Извлекает из относительного URL path часть. Если присутствет завершающий "/", он будет удален. + * @param {string} url - url для обработки + * @return {string} - path часть url + */ + getURLPath(url) { + const urlObject = new URL(url, window.location.origin); + return urlObject.pathname === '/' ? '/' : urlObject.pathname.replace(/\/$/, ''); + } + + /** + * Возвращает объект get параметров, полученних из относительного url + * @param {string} url - url + * @return {Object} содержащий get параметры + */ + getGetParams(url) { + const urlObject = new URL(url, window.location.origin); + return Object.fromEntries(urlObject.searchParams); + } + + /** + * Обрабатывает переданный url (относительный). Извлекает path и get параметры. + * @param {string} url + * @return {Object|null} + */ + processURL(url) { + const getParams = this.getGetParams(url); + const path = this.getURLPath(url); + + /* eslint-disable-next-line guard-for-in */ + for (const template in this._routes) { + const pathParams = this.getPathParams(path, template); + if (pathParams) { + /* Найден подходящий шаблон и параметры: */ + return { + urlData: {url, pathParams, getParams}, + controller: this._routes[template], + }; + } + } + + return null; + } + + /** + * Метод пробует применить template к path и извлечь параметры + * @param {string} path - path часть url + * @param {string} template - шаблон url + * @return {Object|null} возвращает объект с параметрами, + * либо, если path не соотв. template - undefined + */ + getPathParams(path, template) { + /* Разделяем path и template на элементы - подстроки ограниченные "/". */ + const pathElements = path.split('/'); + const templateElements = template.split('/'); + + if (pathElements.length !== templateElements.length) { + return null; + } + + const params = {}; + + /* Проверяем каждый "элемент" шаблона и path'a: */ + return templateElements.every((part, index) => { + if (part.startsWith('<') && part.endsWith('>')) { + /** + * Если исследуемый part отвечает за захват переменной, то он имеет форму + * . Пример: "/home/page/" - щаблон состоит из 3х частей, + * 3я часть ("") захватывает переменную pageNo в из подходящего path. + * Для path'a "/home/page/100" шаблон определит, что "pageNo = 100". + */ + const key = part.slice(1, part.length - 1); + const value = pathElements[index]; + params[key] = Number(value) || value; + } else if (part !== pathElements[index]) { + /** + * Если же part это обычная часть шаблона (как "home" и "page" части из примера выше), + * то у подходящего под template path'a также должна совпадать соотв. часть. + */ + return false; + } + }) ? params : null; } }