From 47fe69bc650ec37ccb93bfead2bdfca57d975c68 Mon Sep 17 00:00:00 2001
From: Nikolay <46225163+vigdorov@users.noreply.github.com>
Date: Sun, 19 Jul 2020 16:19:39 +0300
Subject: [PATCH] =?UTF-8?q?HM-56.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?=
=?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?=
=?UTF-8?q?=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC?=
=?UTF-8?q?=D0=BE=D1=82=D1=80=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2=20?=
=?UTF-8?q?=D1=81=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8?=
=?UTF-8?q?=D0=B5=D0=B9,=20=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86?=
=?UTF-8?q?=D0=B8=D0=B5=D0=B9=20(#20)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/StorageLogsAPI.js | 15 +++
src/api/StorageServiceAPI.js | 4 +-
src/api/consts.js | 6 +-
src/app.html | 45 +++++++
src/app.js | 2 +
src/components/component/Component.js | 11 +-
src/components/logs-filters/LogsFilters.js | 25 ++++
src/components/logs-filters/index.js | 3 +
src/components/logs-page/LogsPage.js | 83 +++++++++++++
src/components/logs-page/index.js | 3 +
.../navigation-buttons-component/constants.js | 4 +-
src/components/pagination/Pagination.js | 111 ++++++++++++++++++
src/components/pagination/index.js | 3 +
src/components/table/HeaderCol.js | 13 ++
src/components/table/Table.js | 89 ++++++++++++++
src/components/table/TableRow.js | 31 +++++
src/components/table/index.js | 3 +
src/services/RouteService.js | 27 ++++-
src/utils/elementUtils.js | 34 ++++++
src/utils/urlUtils.js | 18 +++
20 files changed, 520 insertions(+), 10 deletions(-)
create mode 100644 src/api/StorageLogsAPI.js
create mode 100644 src/components/logs-filters/LogsFilters.js
create mode 100644 src/components/logs-filters/index.js
create mode 100644 src/components/logs-page/LogsPage.js
create mode 100644 src/components/logs-page/index.js
create mode 100644 src/components/pagination/Pagination.js
create mode 100644 src/components/pagination/index.js
create mode 100644 src/components/table/HeaderCol.js
create mode 100644 src/components/table/Table.js
create mode 100644 src/components/table/TableRow.js
create mode 100644 src/components/table/index.js
create mode 100644 src/utils/elementUtils.js
diff --git a/src/api/StorageLogsAPI.js b/src/api/StorageLogsAPI.js
new file mode 100644
index 0000000..3b0d702
--- /dev/null
+++ b/src/api/StorageLogsAPI.js
@@ -0,0 +1,15 @@
+import axios from 'axios';
+import {API_URL, ENDPOINTS} from './consts';
+
+class StorageLogsApi {
+ URL = `${API_URL}${ENDPOINTS.SERVER_LOGS}`;
+
+ request = async () => {
+ const {data} = await axios.get(this.URL);
+ return data;
+ }
+}
+
+const storageLogsApi = new StorageLogsApi();
+
+export default storageLogsApi;
diff --git a/src/api/StorageServiceAPI.js b/src/api/StorageServiceAPI.js
index bac842d..8958885 100644
--- a/src/api/StorageServiceAPI.js
+++ b/src/api/StorageServiceAPI.js
@@ -1,6 +1,6 @@
import axios from 'axios';
-import {API_URL, ENDPOINT, TESTING_HEADERS} from './consts';
+import {API_URL, ENDPOINTS, TESTING_HEADERS} from './consts';
/**
* @interface Store
@@ -17,7 +17,7 @@ import {API_URL, ENDPOINT, TESTING_HEADERS} from './consts';
* @class
*/
class StorageServiceApi {
- URL = `${API_URL}${ENDPOINT}`;
+ URL = `${API_URL}${ENDPOINTS.STORE}`;
get defaultConfig () {
if (location.hostname.includes('localhost')) {
diff --git a/src/api/consts.js b/src/api/consts.js
index 9a32147..7f23508 100644
--- a/src/api/consts.js
+++ b/src/api/consts.js
@@ -1,7 +1,11 @@
export const API_URL = 'http://api.storage.vigdorov.ru';
-export const ENDPOINT = '/store';
+export const ENDPOINTS = {
+ STORE: '/store',
+ CLIENT_LOGS: '/logs/client',
+ SERVER_LOGS: '/logs/server',
+};
export const TESTING_HEADERS = {
'Api-Name': 'store-service-test',
diff --git a/src/app.html b/src/app.html
index 02eec38..ca7f3ce 100644
--- a/src/app.html
+++ b/src/app.html
@@ -66,6 +66,51 @@
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app.js b/src/app.js
index d5f1505..255e59a 100644
--- a/src/app.js
+++ b/src/app.js
@@ -8,6 +8,7 @@ import navMenuButtons from './components/navigation-buttons-component/NavButtonC
import {NAV_MENU} from './components/navigation-buttons-component/constants';
import routeService from './services/RouteService';
import RouterPagesContainer from './components/router-pages-container/index';
+import LogsPage from './components/logs-page/index';
navMenuButtons.render(NAV_MENU);
@@ -24,6 +25,7 @@ const routerPagesContainer = new RouterPagesContainer();
routerPagesContainer.addRoutes([
{url: '/', pageComponent: MainPage},
{url: '/api', pageComponent: ApiPage},
+ {url: '/logs', pageComponent: LogsPage},
]);
/**
diff --git a/src/components/component/Component.js b/src/components/component/Component.js
index 0be98e5..bb019ab 100644
--- a/src/components/component/Component.js
+++ b/src/components/component/Component.js
@@ -43,12 +43,19 @@ class Component extends EmitService {
}
/**
- * Метод уничтожения компонента. Удаляет элемент из верстки, снимает обработчики и очищает подписки
+ * Метод удаляет всех слушателей текущего компонента
*/
- destroy = () => {
+ clearListeners = () => {
this._listeners.forEach(({element, eventName, listener}) => {
element.removeEventListener(eventName, listener);
});
+ }
+
+ /**
+ * Метод уничтожения компонента. Удаляет элемент из верстки, снимает обработчики и очищает подписки
+ */
+ destroy = () => {
+ this.clearListeners();
this.mainNode.remove();
this._listeners = [];
this.clearSubscribes();
diff --git a/src/components/logs-filters/LogsFilters.js b/src/components/logs-filters/LogsFilters.js
new file mode 100644
index 0000000..6550391
--- /dev/null
+++ b/src/components/logs-filters/LogsFilters.js
@@ -0,0 +1,25 @@
+import Component from '../component/index';
+import routeService from '../../services/RouteService';
+
+class LogsFilters extends Component {
+ constructor (parentNode) {
+ super('#logs-filters', parentNode);
+
+ this.messageInput = this.mainNode.querySelector('#logs-filter-message');
+
+ const {message = ''} = routeService.getUrlData().query;
+
+ this.messageInput.value = message;
+
+ this.addEventListener(this.mainNode, 'submit', (event) => {
+ event.preventDefault();
+
+ routeService.pushQuery({
+ message: this.messageInput.value,
+ });
+ });
+
+ }
+}
+
+export default LogsFilters;
diff --git a/src/components/logs-filters/index.js b/src/components/logs-filters/index.js
new file mode 100644
index 0000000..02d0ce9
--- /dev/null
+++ b/src/components/logs-filters/index.js
@@ -0,0 +1,3 @@
+import LogsFilters from './LogsFilters';
+
+export default LogsFilters;
diff --git a/src/components/logs-page/LogsPage.js b/src/components/logs-page/LogsPage.js
new file mode 100644
index 0000000..611b4ba
--- /dev/null
+++ b/src/components/logs-page/LogsPage.js
@@ -0,0 +1,83 @@
+import Component from '../component/index';
+import Table from '../table';
+import storageLogsApi from '../../api/StorageLogsAPI';
+import Pagination from '../pagination';
+import LogsFilters from '../logs-filters';
+import routeService from '../../services/RouteService';
+import {prepareToNumber} from '../../utils/urlUtils';
+
+const COLS = [
+ {id: '_id', label: 'id', width: '240px'},
+ {id: 'date', label: 'Дата', width: '150px'},
+ {id: 'type', label: 'Тип', width: '70px'},
+ {id: 'message', label: 'Сообщение', width: '200px'},
+ {id: 'trace', label: 'Стек', width: '200px'},
+];
+
+const ELEMENTS_ON_PAGE = 15;
+
+class LogsPage extends Component {
+ constructor (mainNodeSelector, parentNode) {
+ super(mainNodeSelector, parentNode);
+
+ this.filters = new LogsFilters(this.mainNode);
+
+ this.table = new Table(this.mainNode, COLS);
+
+ this.pagination = new Pagination(this.mainNode);
+
+ routeService.onChange(this.renderTable);
+
+ this.pagination.onPageChange((pageNumber) => {
+ const start = (pageNumber - 1) * ELEMENTS_ON_PAGE;
+ const end = start + ELEMENTS_ON_PAGE;
+ const rows = this.logList.slice(start, end + 1);
+
+ this.table.render(rows);
+ });
+
+ this.loadLogList();
+ }
+
+ loadLogList = async () => {
+ this.logList = await storageLogsApi.request();
+ this.renderTable();
+ }
+
+ filterRows = (queryMessage) => {
+ return this.logList.reduce((memo, row) => {
+ const message = row.message.toLowerCase();
+ const searchMessage = (queryMessage || '').toLowerCase();
+ if (searchMessage === '') {
+ memo.push(row);
+ return memo;
+ }
+ if (message.includes(searchMessage)) {
+ const replaceMessage = new RegExp(searchMessage, 'g');
+ const newText = message.replace(replaceMessage, `${searchMessage}`);
+ memo.push({
+ ...row,
+ message: newText,
+ });
+ }
+ return memo;
+ }, []);
+ }
+
+ renderTable = () => {
+ const {query} = routeService.getUrlData();
+ const filterRows = this.filterRows(query.message);
+ const countPages = Math.ceil(filterRows.length / ELEMENTS_ON_PAGE);
+ const pageNumber = prepareToNumber(query.pageNumber, countPages);
+ this.pagination.changeCountPages(countPages);
+
+ const start = (pageNumber - 1) * ELEMENTS_ON_PAGE;
+ const end = start + ELEMENTS_ON_PAGE;
+ const rows = filterRows.slice(start, end + 1);
+
+ this.table.render(rows);
+
+ }
+}
+
+export default LogsPage;
diff --git a/src/components/logs-page/index.js b/src/components/logs-page/index.js
new file mode 100644
index 0000000..a3f4186
--- /dev/null
+++ b/src/components/logs-page/index.js
@@ -0,0 +1,3 @@
+import LogsPage from './LogsPage';
+
+export default LogsPage;
diff --git a/src/components/navigation-buttons-component/constants.js b/src/components/navigation-buttons-component/constants.js
index feacb23..b0a3761 100644
--- a/src/components/navigation-buttons-component/constants.js
+++ b/src/components/navigation-buttons-component/constants.js
@@ -8,7 +8,7 @@ export const NAV_MENU = [
url: '/api'
},
{
- title: 'Еще что-то',
- url: '/something'
+ title: 'Журнал',
+ url: '/logs'
},
];
diff --git a/src/components/pagination/Pagination.js b/src/components/pagination/Pagination.js
new file mode 100644
index 0000000..95426d6
--- /dev/null
+++ b/src/components/pagination/Pagination.js
@@ -0,0 +1,111 @@
+import Component from '../component/index';
+import routeService from '../../services/RouteService';
+import {createElement} from '../../utils/elementUtils';
+import {prepareToNumber} from '../../utils/urlUtils';
+
+const LEFT_ICON = '«';
+const RIGHT_ICON = '»';
+
+const CHAGE_PAGE = 'changePage';
+
+class Pagination extends Component {
+ buttons = [];
+
+ constructor (parentNode) {
+ super('#pagination', parentNode);
+
+ this.currentPage = 0;
+ this.countPages = 0;
+
+ this.container = this.mainNode.querySelector('.pagination');
+
+ routeService.onChange(() => {
+ this.renderButtons();
+ });
+ }
+
+ renderOneButton = (text, isDisabled = false) => {
+ const li = createElement({
+ tagName: 'li',
+ parentNode: this.container,
+ options: {
+ className: 'page-item',
+ disabled: isDisabled,
+ },
+ });
+ const button = createElement({
+ tagName: 'button',
+ parentNode: li,
+ options: {
+ className: 'page-link pe-auto',
+ innerHTML: text,
+ },
+ });
+ if (isDisabled) {
+ li.classList.add('disabled');
+ button.setAttribute('disabled', 'true');
+ }
+ if (text === this.currentPage) {
+ li.classList.add('active');
+ }
+ this.addEventListener(button, 'click', () => {
+ const nextPage = (() => {
+ if (text === LEFT_ICON) {
+ return this.currentPage - 1;
+ }
+ if (text === RIGHT_ICON) {
+ return this.currentPage + 1;
+ }
+ return text;
+ })();
+
+ routeService.pushQuery({
+ pageNumber: nextPage,
+ });
+ });
+ this.buttons.push(li);
+ }
+
+ clearButtons = () => {
+ this.buttons.forEach((b) => {
+ b.remove();
+ });
+
+ this.clearListeners();
+
+ this.buttons = [];
+ }
+
+ changeCountPages = (countPages) => {
+ if (countPages !== this.countPages) {
+ this.countPages = countPages;
+
+ this.renderButtons();
+ }
+ }
+
+ renderButtons = () => {
+ this.clearButtons();
+ const {pageNumber} = routeService.getUrlData().query;
+ this.currentPage = prepareToNumber(pageNumber, this.countPages);
+
+ const start = this.currentPage < 3 ? 1 : this.currentPage - 2;
+ const end = start + 4 > this.countPages ? this.countPages : start + 4;
+
+ this.renderOneButton(LEFT_ICON, this.currentPage <= 1);
+
+ for (let i = start; i <= end; i += 1) {
+ this.renderOneButton(i);
+ }
+
+ this.renderOneButton(RIGHT_ICON, this.currentPage >= this.countPages);
+
+ this.next(CHAGE_PAGE, this.currentPage);
+ }
+
+ onPageChange = (listener) => {
+ this.subscribe(CHAGE_PAGE, listener);
+ }
+}
+
+export default Pagination;
diff --git a/src/components/pagination/index.js b/src/components/pagination/index.js
new file mode 100644
index 0000000..9ed530b
--- /dev/null
+++ b/src/components/pagination/index.js
@@ -0,0 +1,3 @@
+import Pagination from './Pagination';
+
+export default Pagination;
diff --git a/src/components/table/HeaderCol.js b/src/components/table/HeaderCol.js
new file mode 100644
index 0000000..83320e2
--- /dev/null
+++ b/src/components/table/HeaderCol.js
@@ -0,0 +1,13 @@
+import Component from '../component/index';
+
+class HeaderCol extends Component {
+ constructor (parentNode, col) {
+ super('#uni-table-th', parentNode);
+
+ this.id = col.id;
+ this.mainNode.textContent = col.label;
+ this.mainNode.style.minWidth = col.width ?? '';
+ }
+}
+
+export default HeaderCol;
diff --git a/src/components/table/Table.js b/src/components/table/Table.js
new file mode 100644
index 0000000..81eb2ea
--- /dev/null
+++ b/src/components/table/Table.js
@@ -0,0 +1,89 @@
+import Component from '../component/index';
+import HeaderCol from './HeaderCol';
+import TableRow from './TableRow';
+
+const ROW_CLICK = 'ROW_CLICK';
+
+/**
+ * @interface Col
+ * @type {Object}
+ * @property {string} id - id колонки, которая соответствует ключу объекта
+ * @property {string} label - читаемое название колонки
+ */
+
+/**
+ * Класс создания Таблицы. В нее при создании передаем массив с колонками, а сами строчки рендерим в тот момент,
+ * когда у нас есть на руках сформированные данные
+ * @param {Node} parentNode
+ * @param {Col[]} cols
+ *
+ * @example
+ * const table = new Table(document.body, [
+ * {id: 'key', label: 'Ключ'},
+ * {id: 'value', label: 'Значение'},
+ * {id: 'author', label: 'Автор'},
+ * ]);
+ *
+ * table.render([
+ * {key: 'testApi', value: '', author: 'Tester'},
+ * {key: 'testApi2', value: '', author: 'Tester'},
+ * ]);
+ */
+class Table extends Component {
+ constructor (parentNode, cols) {
+ super('#uni-table', parentNode);
+
+ this.theadTr = this.mainNode.querySelector('thead tr');
+ this.tbody = this.mainNode.querySelector('tbody');
+
+ this.cols = cols;
+ this.renderHeader();
+ }
+
+ /**
+ * Метод нужен для отрисовки шапки таблицы, вызывается конструктором самостоятельно
+ */
+ renderHeader = () => {
+ if (this.headerCols) {
+ this.headerCols.forEach((col) => {
+ col.destroy();
+ });
+ }
+ this.headerCols = this.cols.map((col) => new HeaderCol(this.theadTr, col));
+ }
+
+ /**
+ * Метод рендерит строчки таблицы
+ * @param {{[key: string]: string}[]} rows - список строк для рендера
+ */
+ render = (rows) => {
+ if (this.rows) {
+ this.rows.forEach((row) => {
+ row.destroy();
+ });
+ }
+
+ this.rows = rows.map((row) => {
+ const rowComponent = new TableRow(this.tbody, this.cols, row);
+ rowComponent.onClick((event) => {
+ this.next(ROW_CLICK, event, row);
+ });
+ return rowComponent;
+ });
+ }
+
+ /**
+ * Метод для подписки на клики по строчкам, передает event и данные строчки
+ * @param {Function} listener - слушатель
+ *
+ * @example
+ * table.onRowClick((event, row) => {
+ * // Ваш код
+ * });
+ */
+ onRowClick = (listener) => {
+ this.subscribe(ROW_CLICK, listener);
+ }
+}
+
+export default Table;
diff --git a/src/components/table/TableRow.js b/src/components/table/TableRow.js
new file mode 100644
index 0000000..217f91c
--- /dev/null
+++ b/src/components/table/TableRow.js
@@ -0,0 +1,31 @@
+import Component from '../component/index';
+import {createElement} from '../../utils/elementUtils';
+
+const CLICK = 'click';
+
+class TableRow extends Component {
+ constructor (parentNode, cols, row) {
+ super('#uni-table-row', parentNode);
+
+ cols.forEach((col) => {
+ const cell = createElement({
+ tagName: 'td',
+ parentNode: this.mainNode,
+ options: {
+ className: 'text-break'
+ },
+ });
+ cell.innerHTML = row[col.id];
+ });
+
+ this.mainNode.addEventListener(CLICK, (event) => {
+ this.next(CLICK, event);
+ });
+ }
+
+ onClick = (listener) => {
+ this.subscribe(CLICK, listener);
+ }
+}
+
+export default TableRow;
diff --git a/src/components/table/index.js b/src/components/table/index.js
new file mode 100644
index 0000000..de4c7d5
--- /dev/null
+++ b/src/components/table/index.js
@@ -0,0 +1,3 @@
+import Table from './Table';
+
+export default Table;
diff --git a/src/services/RouteService.js b/src/services/RouteService.js
index f45f9a9..d4824f8 100644
--- a/src/services/RouteService.js
+++ b/src/services/RouteService.js
@@ -64,7 +64,7 @@ class RouteService extends EmitService {
/**
* Метод перехода по маршрутам
* @param {string} url - принимает маршрут для перехода
- * @param {query} query - объект с парами ключ-значение для url
+ * @param {Object} query - объект с парами ключ-значение для url
* @example
* // Переход по заданному url
* _.goTo('/users', {
@@ -73,12 +73,33 @@ class RouteService extends EmitService {
* });
* // Это создаст строку в url - site.ru/users?key=testApi&author=Petrov
*/
- goTo = (url, query) => {
- const urlWithQuery = makeUrlWithQuery(url, query);
+ goTo = (url, query = {}) => {
+ const prepareQuery = Object.entries(query)
+ .reduce((memo, [key, value]) => {
+ if (value) {
+ memo[key] = value;
+ }
+ return memo;
+ }, {});
+ const urlWithQuery = makeUrlWithQuery(url, prepareQuery);
this.history.pushState({}, '', urlWithQuery);
this.generateNext();
}
+ /**
+ * Обновляет query в url сохраняя текущий url
+ * @param {Object} newQuery - объект с новыми query ключами-значениями
+ * @param {boolean} isClear - указать true, чтобы очистить предыдущий query, иначе параметры
+ * смерджатся с приоритетом у нового query
+ */
+ pushQuery = (newQuery, isClear = false) => {
+ const {url, query} = this.getUrlData();
+ this.goTo(url, {
+ ...(isClear ? {} : query),
+ ...newQuery,
+ });
+ }
+
/**
* С помощью этого метода подписываемся на событие изменения роута.
* @param {RouterListener} listener - слушатель для события изменения роута
diff --git a/src/utils/elementUtils.js b/src/utils/elementUtils.js
new file mode 100644
index 0000000..abf70a8
--- /dev/null
+++ b/src/utils/elementUtils.js
@@ -0,0 +1,34 @@
+/**
+ * @interface CreateElementProps
+ * @type {Object}
+ * @property {string} tagName - имя создаваемого тега
+ * @property {Node} parentNode - родительский Node в который поместить элемент
+ * @property {Object} options - опции, которые можно присвоить объекту
+ * @property {Object} args - аргументы, которые нужно прикрепить к Node
+ */
+
+/**
+ * Функция создания элементов
+ * @function createElement
+ * @param {CreateElementProps} createElementProps - параметры для функции
+ * @returns {Node}
+ */
+export const createElement = (createElementProps) => {
+ const {tagName, parentNode, options, args} = createElementProps;
+ const element = document.createElement(tagName);
+ if (options) {
+ Object.entries(options)
+ .forEach(([key, value]) => {
+ element[key] = value;
+ });
+ }
+
+ if (args) {
+ Object.entries(args)
+ .forEach(([attr, value]) => {
+ element.setAttribute(attr, value);
+ });
+ }
+ parentNode.appendChild(element);
+ return element;
+};
diff --git a/src/utils/urlUtils.js b/src/utils/urlUtils.js
index 52db433..c70023a 100644
--- a/src/utils/urlUtils.js
+++ b/src/utils/urlUtils.js
@@ -1,4 +1,5 @@
import {stringify} from 'querystring';
+import toNumber from 'lodash/toNumber';
/**
* Из маршрута и объекта query создает строку для url
@@ -9,3 +10,20 @@ export const makeUrlWithQuery = (url = '', query = {}) => {
const stringQuery = stringify(query);
return url + (stringQuery ? `?${stringQuery}` : '');
};
+
+/**
+ * Преобразует текстовое значение из url'a в номер страницы. Если не получается, то возвращает 1 страницу.
+ * @param {unknown} number - значение из url'a, которое мы хотим превратить в номер страницы
+ * @param {number} countPages - общее количество страниц
+ */
+export const prepareToNumber = (number, countPages) => {
+ const prepare = toNumber(number);
+ const prepareNaN = Number.isNaN(prepare) ? 1 : prepare;
+ if (prepareNaN < 1) {
+ return 1;
+ }
+ if (prepareNaN > countPages) {
+ return countPages;
+ }
+ return prepareNaN;
+};