Необходимо написать SPA web-приложение, позволяющее просматривать список сообщения, создавать новые сообщения и редактировать существующие. Общая структура приложения уже готова, часть компонентов реализована, требуется дописать недостающий код в соответствие с описанием и требованиями.
- Примерное время выполнения:
6-12
часов - Необходимые навыки:
- знание Backbone.js
- знание Marionette.js (у нас используется старая версия
2.4.7
- знакомство с Epoxy.js ссылка на репозиторий или умение быстро вникать в документацию
- умение верстать шаблоны с помощью pug и sass
- умение писать тесты (желательно знакомство с
tape
, у нас используется blue-tape
- Документация API
Node.js version >= 11
: для запуска тестов сервера нужна нода, поддерживающаяES2016
и частьES2017
:async/await
typescript@3.1.6
или свежее (это для написанияd.ts
файлов определений)
- Склонировать репозиторий
- Создаем репозиторий на гитхабе, куда будет опубликовано решение (например,
https://github.com/B2CMessenger/hiring_solution
) - Теперь надо в папке с проектом обновить
remotes
дляgit
:git remote rename origin upstream
- переименовываемorigin
вupstream
git remote set-url --push upstream DISABLE
- запрещаем пуш вupstream
git remote add origin https://github.com/B2CMessenger/hiring_solution.git
- добавляем свой репозиторий какorigin
git push origin
- пушим в свой репозиторий- создаем ветку
solution
, в которой собственно и будет происходить разработка решения npm install
- Коммиты тестового задания из
upstream
(https://github.com/B2CMessenger/hiring
) должны быть в истории коммитов решения - Также надо время от времени проверять обновления
upstream
и либо ребейзить веткуsolution
на HEADmaster
ветки, либо мержить эти обновления - Желательно, чтобы была видна история коммитов разработки решения, а не один огромный коммит со всем решением
- Для публикации решения надо предоставить нам доступ к репозиторию решения
- Также желательно указать, сколько примерно времени понадобилось для разработки решения
Для удобства разработки в package.json
добавлены скрипты для live-reload
и автоматического запуска тестов.
npm run live
- запускает локальный сервер с API с адресом http://localhost:56668
, запускает webpack-dev-server
с адресом http://localhost:56666
, а также автоматически исполняет тесты из папки test
при изменении исходных кодов приложения и самих тестов
npm run serve
- отдельный запуск сервера с API с адресом http://localhost:56668
npm run build
- очистит папку www
, прогонит тесты и соберет бандл и index.html
в папке www
Прилоежние представляет из себя Single Page Application
запускаемое в браузере.
Приложение позволяет просматривать список сообщений, добавлять и редактировать сообщения.
Приложение состоит их одного экрана на котором присутствуют следующие элементы:
- Хэдер приложения состоит из
- (если пользователь не залогинен) Формы логина: поле ввода имени пользователя и кнопки
Login
- или (если пользователь залогинен) кнопки
Add message
, имени текущего пользователя и кнопкиLogout
- (если пользователь не залогинен) Формы логина: поле ввода имени пользователя и кнопки
- Область контента представляет из себя список сообщений, полученный с сервера. Каждое сообщение состоит из:
- Хэдера сообщения с указанием автора и даты обновления
- Заголовка сообщения
- Текста сообщения
- Футера сообщения с обозначением заряда сообщения и кнопками
Edit
иDelete
- Футер сообщения содержит в себе редактор сообщения:
- Поле ввода заголовка сообщения
- Поле ввода текста сообщения
- Кнопка
Create
илиUpdate
Проект представляет из себя стандартный пакет npm
. Приложение написано на ES7
, для сборки используется webpack
. В качестве фреймворка используется @say2b/framework
- по сути Marionette.js
+ Epoxy.js
+ различные дополнения типа Model.proxies
и т.п.
src
- папка с исходными кодами проектаindex.js
- точка входаindex.html
- шаблонHTML
для страницы приложенияindex.scss
- глобальные стили для приложенияAjaxError.js
- класс-обертка дляError
, с более удобной сериализацией ошибокjQuery.ajax()
settings.js
- модуль с глобальными настройками приложенияApp
- папка с исходным кодом классаApp
- подклассMarionette.Application
Editor
- папка с исходным кодом редактора сообщенийMessage
- папка с исходным кодом контейнера сообщений, модели сообщений и виджета сообщенияUser
- папка с исходным кодом модели пользователя и виджета авторизации и отображения пользователяutils
- папка со вспомогательными модулямиrandomPrettyColor.js
- функция для генерирования цвета: либо по строке, либо рандомный цвет
types
- папка дляd.ts
файлов-описаний
test
- папка тестированияtests
- папка с тестами приложения, тесты для каждого компонента должны быть в файле с названием компонентаacceptance
- папка с приемочными тестами, этут папку и файлы в ней изменять нельзя!acceptance.js
- приемочные тесты, этот файл изменять нельзяindex.js
- сюда надо импортировать тесты для компонентов
server
- папка с локальным сервером API Менять сервер для выполнения задания нельзя!www
- папка, куда будет собрано приложениеdocs
- используется для хранения материалов для документацииwebpack
- используется для хранения конфигурацииwebpack
- Работоспособность в последней версии
chrome
- Прохождение всех тестов
- Адаптивность - в конце приложение должно выглядеть как-то вот так:
Мобильные устройства:
Планшеты и десктоп:
- Для описания класса используется
ES6
синтаксисclass
- Для указания атрибутов класса следует использовать следующие декораторы (на примере наследования
ItemView
):@ItemView.options({ ... })
- декоратор для декларативного описания опцийслужит в первую очередь для документирования@ItemView.options({ model: Required, parentViewModel: Optional })
@ItemView.events({ 'eventName': (param1, param2) => {} })
- декоратор для декларативного описания событий, которые данный виджет может вызыватьслужит в первую очередь для документирования, не влияет на свойства класса@ItemView.event({ 'edit': (messageModel) => {} // событие `edit`, с которым передается `messageModel` })
@ItemView.properties({ })
- декоратор для добавления свойств в прототип классаслужит для указания свойств класса, через него в основном и идет настройка класса@ItemView.properties({ tagName: 'form', className: 'user-panel-view', ui: { //... } })
@ItemView.extendProperties({ })
- аналогично предыдущему декортору, но в отличие от него позволяет расширять свойства, а не перезаписывать их
- Предпочтителен следущий порядок объвления класса:
@ItemView.options
- в самом начале указываем с каким опциями создается экземпляр класса@ItemView.events
- потом декларируем, какие события он вызывает@ItemView.properties
или@Model.properties
- потом уже расписывает свойства и параметры:- Для виджетов:
tagName
,className
,template
childView
,getChildView
,childViewOptions
,viewComparator
,reorderOnSort
ui
computeds
,bindings
bindingHandlers
,bindingFilters
,bindingSources
templateHelpers
events
,triggers
,modelEvents
,collectionEvents
,childEvents
- Для моделей/коллекций:
model
,comparator
defaults
computeds
proxies
- Для виджетов:
- После этого уже идет
class Class extends ItemView {
- сначала описываем нестандартные публичные методы типа
markAsRead()
,increaseCharge()
- потом
constructor
(если нужен нестандартный), initialize()
onRender()
,onAttach()
- стандартные публичные переопределенные методы типа
render()
,sync()
... - стандартные приватные переопределенные методы типа
_prepareModel()
,_ensureElement()
- нестанадртные приватные методы
_onModelNameChanged()
,_renderFooter()
- сначала описываем нестандартные публичные методы типа
- Для указания атрибутов класса следует использовать следующие декораторы (на примере наследования
- Если функция возвращает промис, то предпочтительнее сразу указать ее как
async
- Все переопределения стандартных полей должны удовлетворять стандартным соглашениям для этого поля. Например, если переопределяем
Model.sync(method, model, options)
, то переопределенный метод обязан вернутьjqXHR
, обязан вызватьoptions.error
илиoptions.success
с правильными аргументами, а также обязан вызвать событиеrequest
с правильными параметрами. - Шаблоны для виджетов должны быть написаны на
pug
и помещены рядом с файлом самого виджета, с тем же названием - Стили для каждого виджета должны быть написаны на
sass/scss
и помещены рядом с файлом самого виджета, с тем же названием - Тесты для каждого компонента должны быть в
test/tests/
папке, название файла - название самого компонента
Подкласс Marionette.Application
. Хранит в себе и управляет userModel
- модель пользователя, messageCollection
- коллекция сообщений, viewModel
- корневая ViewModel
для всех виджетов. Управляет виджетами UserPanelView
- виджет панели авторизации пользователя, MessagesView
- виджет сообщений, EditorView
- виджет редактора сообщения. Отвечает за общую бизнес-логику приложения: авторизация пользователя, загрузка сообщений, инициализация и отображение редактора сообщений в ответ на запрос создания нового сообщения или редактирование существующего.
Общая структура и код создания/уничтожения уже готовы.
App.onStart()
:- в
header
регионе должен отобразиться виджетUserPanelView
- в
content
регионе должен отобразиться виджетMessagesView
- если в
localStorage
был сохраненuserName
, то должна произойти автоматическая авторизация:this.userModel.fetch()
илиthis.userModel.save({ name: ... })
app.userModel
должна вызывать событиеrequest
- в
App._onUserModelIsLoggedInChange(...)
: при измененииApp.userModel["isLoggedIn"]
:- если пользователь залогинился, то сразу же должны подгрузитья сообщения в
App.messageCollection
, а также вlocalStorage["userName"]
должно быть сохраненоApp.userModel["name"]
- если пользователь разлогинился, то
localStorage["userName"]
должно быть удалено, а также, если вfooter
регионе отображается редактор, то он должен быть уничтожен (виджет редактора должен быть удален из регионаfooter
)
- если пользователь залогинился, то сразу же должны подгрузитья сообщения в
- При появлении события
add
отUserPanelView
App
должна отобразить редактор для нового сообщения вfooter
регионе. - При появлении события
edit
отMessagesView
App
должна отобразить редактор для этого сообщения вfooter
регионе.
Стили для App
уже написаны и не требуют доработок. Верстка не требуется.
Покрытие тестами не требуется
- У этого виджета есть два состояния: пользователь авторизован, пользователь не авторизован
- виджет в качестве
options
принимает:model: UserModel
- обязательныйparentViewModel?: ViewModel
- опциональный,ViewModel
родителя (используется для иерархической логикиViewModel["disabled"]
поля)
- Виджет состоит из поля ввода имени пользователя и кнопки
Login
- поле ввода пользователя должно иметь атрибут
data-js-name-input
(используется в тестах) - поле ввода пользователя имеет плейсхолдер
Enter your name
- кнопка
Login
должна иметь атрибутdata-js-submit
(используется в тестах) - при нажатии на кнопку
Login
или при нажатииEnter
в поле ввода имени пользователя (submit
событие для формы) должна происходить авторизация пользователя (вызовthis.model.fetch()
илиthis.model.save(...)
) - непосредственно во время исполнения запроса на авторизацию поле ввода и кнопка должны быть заблокированы
- после успешного выполнения запроса
UserModel
, приложение и виджет переходят в состояние авторизованного пользователя
- Виджет состоит из кнопки
Add message
, надписи с имененм пользователя и кнопкиLogout
- кнопка
Add message
должна иметь атрибутdata-js-add
(используется в тестах) - при нажатии на кнопку
Add Message
UserPanelView
должен создать событиеadd(void)
, и соот-но, в футере приложения должен отобразиться редактор нового сообщения. Следует учесть, что, если редактор уже был открыт в режиме создания нового сообщения, то его не следует пересоздавать - надпись с имененм пользователя должна иметь атрибут
data-js-user-name
(используется в тестах) и отображать имя текущего пользователя. - кнопка
Logout
должна иметь атрибутdata-js-submit
(используется в тестах) - при нажатии на кнопку
Logout
модельUserModel
должна переходить в неавторизованное состояние:UserModel["name"]
иUserModel["token"]
должны очищаться, соот-но приложение и виджет переходят в состояние неавторизованного пользователя
Нужно будет сверстать шаблон для этого виджета, а также прописать стили. Вот пара тонких моментов:
- Если имя пользователя не помещается на экране, то оно должно быть обрезано с использованием
text-overflow: ellipsis
- Кнопка
Add message
должна быть у левого края, а поле ввода, имя пользователя и кнопкаLogin/Logout
должны быть у правого края - Все элементы должны быть выровнены по вертикали по
baseline
Покрытие тестами не требуется. Но желательно.
Модель пользователя. Содержит в себе name
- имя пользователя, token
- токен авторизации, isLoggedIn
- вычислимое(computed
см. Epoxy.js computeds) поле статуса авторизации пользователя.
Для этой модели необходимо реализовать методы синхронизации с API
- Необходимо покрыть тестами
[isLoggedIn]
поле - Методы
fetch()
,save()
,destroy()
Виджет списка сообщений.
- Сообщения должны быть отсортированы по
created_at ASC
- При получении события
edit@MessageView
виджет должен отправить событиеedit@MessagesView
приложению, чтобы оно могло открыть редактор для существующего сообщения - Виджет в качестве
options
принимает:collection: MessageCollection
- обязательныйparentViewModel?: ViewModel
- опциональный,ViewModel
родителя (используется для иерархической логикиViewModel["disabled"]
поля)
Покрытие тестами не требуется. Но желательно.
Коллекция моделей MessageModel
.
Для этой коллекции необходимо реализовать методы синхронизации с API
- Методы
fetch()
,create()
- Виджет должен иметь атрибут
data-js-message
равныйid
модели (используется в тестах) - Виджет в качестве
options
принимает:model: MessageModel
- обязательныйuserModel: UserModel
- обязательныйparentViewModel?: ViewModel
- опциональный,ViewModel
родителя (используется для иерархической логикиViewModel["disabled"]
поля)
Заголовок виджета сообщения должен содержать в себе следующие элементы:
- Аватарка пользователя, круг с фоном с цветом, соответствующим
author
строке (используетсяutils/randomPrettyColor
), в центре - первая буква имени автора - Элемент с именем пользователя должен иметь атрибут
data-js-author
(используется в тестах) - Дата последнего изменения в формате локали браузера пользователя, элемент должен иметь атрибут
data-js-date
(используется в тестах). При изменении поляupdated_at
должно обновляться только содержимое этого элемента.
Контентная область сообщения. Состоит из темы и текста сообщения
- Элемент с темой сообщения должен иметь атрибут
data-js-subject
- Элемент с текстом сообщения должен иметь атрибут
data-js-text
- При изменении в модели полей
subject
иtext
, виджет не должен полностью перерисовываться черезrender()
, только сами элементы должны быть обновлены
Подвал сообщения. Состоит из заряда сообщения и кнопок Edit
и Delete
- заряд сообщения отображается в виде ряда кнопок от
1
до10
. Каждая кнопка должна иметь атрибутdata-js-charge
, значение которого равно соответствующему заряду. При нажатии на кнопку должны происходить запросы на изменени заряда до соответствующего значения. На время выполнения запросов виджет сообщения должен блокироваться (блокируются все активные элементы виджета). - При изменении в модели поля
charge
, виджет не должен полностью перерисовываться черезrender()
, должно быть обновление только виджета заряда - кнопка
Edit
, должна иметь атрибутdata-js-edit
. Отображается только в случае, если залогиненный пользователь является автором сообщения, также она должна быть либо удалена изDOM
, либо заблокирована, если пользователь не является автором сообщения. - при нажатии на кнопку
Edit
в футере приложения должен отобразиться редактор этого сообщения. Следует учесть, что если редактор уже открыт для этого сообщения, то его не стоит пересоздавать или перезагружать. - кнопка
Delete
, должна иметь атрибутdata-js-delete
. Отображается только в случае, если залогиненный пользователь является автором сообщения, также она должна быть либо удалена изDOM
, либо заблокирована, если пользователь не является автором сообщения. - при нажатии на кнопку
Delete
сообщение должно быть удалено. Следует учесть, что, если у сообщения ненулевой заряд, то перед его удалением необходимо привести его к нулю, для пользователя же эта цепочка запросов должна выглядеть одним непрерывным запросом, на время которого виджет сообщения должен быть заблокирован - при изменении статуса авторизации пользователя кнопки
Edit
иDelete
должны быть обновлены, но при этом не должно происходить полной перерисовки виджета сообщения
Нужно будет сверстать шаблон для этого виджета, а также прописать стили.
- Если имя пользователя не помещается на экране, то оно должно быть обрезано с использованием
text-overflow: ellipsis
Покрытие тестами не требуется. Но желательно.
Модель сообщения.
Для этой коллекции необходимо реализовать методы синхронизации с API
- методы изменения заряда должны быть представлены функциями:
async increaseCharge()
- асинхронное увеличение заряда на1
async decreaseCharge()
- асинхронное уменьшение заряда на1
async setCharge(charge)
- асинхронное приведение заряда к указанному значению
fetch()
save()
destroy()
increaseCharge()
decreaseCharge()
setCharge()
Виджет редактора нового/существующего сообщения. Состоит из поля ввода темы сообщения, поля ввода текста сообщения и кнопки Create/Update
- поле ввода темы сообщения должно иметь атрибут
data-js-subject
- текстовое поле ввода текста сообщения должно иметь атрибут
data-js-text
- при нажатии на кнопку
Create/Update
:- в случае нового сообщения должен отправиться запрос на создание нового сообщения, после этого это сообщение должно быть добавлено в список всех сообщений
- в случае редактирования существующего должен отправиться запрос на изменение сообщения и эти изменения должны быть отражены в списке всех сообщений
- в обоих случаях на время запроса редактор должен быть заблокирован (через атрибут
disabled
для полей ввода и кнопок)
- после добавления нового сообщения список должен прокрутиться до него
- если пользователь разлогинился, то редактор должен быть скрыт
- виджет в качестве
options
принимает:model: MessageModel
- обязательныйparentViewModel?: ViewModel
- опциональный,ViewModel
родителя (используется для иерархической логикиViewModel["disabled"]
поля)
Покрытие тестами не требуется. Но желательно.
Разберем самые частоиспользуемые возможности @say2b/backbone
на примере виджета CustomView
:
Условимся, что модель, передаваемая в виджет может иметь следующие поля:
header: string|null|undefined
- некий заголовокtext: string|null|undefined
- некий текстcount: integer
- некое значение
Т.к. используется новый синтаксис определения классов ES6
, то старый подход через ItemView.extend({...})
является устаревшим.
Свойства прототипа класса стоит задавать через декораторы, которые предоставляются базовыми классами фреймворка:
/* Позволяет задекларировать опции, которые надо передавать в конструктор виджета.
Устанавливает `Class.prototype.options` */
@ItemView.options({
/* Используется `Required` константа, которая на самом деле равна `null`,
позволяет отметить опцию как обязательную.
Указывает, что виджету необходимо передать `model` */
model: Required,
/* Используется `Optional` константа, которая на самом деле равна `null`,
позволяет отметить опцию как необязательную.
Указывает, что виджету можно передать `parentViewModel` */
parentViewModel: Optional
})
/* Позволяет задекларировать нестандартные события, которые вызываются виджетом.
На данный момент не имеет никакого эффекта, используется для документирования */
@ItemView.events({
/* Декларируем событие `caption:click`, в аргументы которого передается строка
`caption` и объект события `e` */
'caption:click': (caption, e) => { }
})
/* Самый многофункциональный декоратор. Напрямую расширяет `Class.prototype` */
@ItemView.properties({
/* Стандартные свойства для Marionette.ItemView */
tagName: 'div',
template,
className: 'custom-view view',
ui: {
input: '[data-js-input]',
caption: '[data-js-caption]'
},
/* Ко всем клссам `*View` из фреймворка уже примешан `Epoxy.View`.
Это позволяет пользоваться всеми возможностями `Epoxy.View`.
Декларируем вычисляемые значения виджета на основе полей из модели.
Эти вычисляемые значения можно использовать в биндингах наряду с полями модели.
Подробнее: см. документацию `Backbone.Epoxy` */
computeds: {
/* Создаем новое `read-only` вычисляемое значение `c_CaptionText`, которое
автоматически обновляется, если меняются поля `header` и `text` модели. */
c_CaptionText: {
deps: ['header', 'text'],
get: (header, text) => (header ? header + ' ' : '') + (text ? '\n' + text : '')
},
/* Создаем новое `read-write` вычисляемое значение `c_Value`, которое по сути
проксирует и нормализует значение поля `value` из модели и нормализует
значение переданное из поля ввода. */
c_Value: {
deps: ['value'],
/* Обработчик `Context -> HTMLElement` */
get: value => value || null,
/* Т.к. данное значение будет использовано в двухстороннем биндинге,
то необходимо предоставить обработчик для `HTMLElement -> Context` сценария*/
set(val) {
/* Фильтруем, нормализуем и записываем в модель */
this.model.set(Number(val) || null)
}
}
},
/* Дальше декларируем сами биндинги в формате:
`selector`:`bindingsListSerialized` */
bindings: {
/* Здесь используется специальный селектор `:el`, который позволяет создать
биндинги к самому(корневому) элементу виджета `this.el`.
Используется хэндлер `classes`, который позволяет добавлять или убирать
классы в зависимости от переданного значения (точнее, от того, является ли
переданное значение `truthy`) */
':el': 'classes:{"has-header":header}',
/* Здесь используется специальный селектор `@ui.input`, который позволяет создать
биндинги элементу из `ui` хэша виджета.
Используется `read-write` хэндлер `value`, который устанавливает двухстороннею
связь между значением `input` и `c_Value`: если `c_Value` изменится, то значение
`input` будет обновлено, если значение `input` изменится в следствие
пользовательского ввода, то `c_Value` будет обновлено.
Используется `read-only` хэндлер `disabled`, который позволяет добавлять или убирать
атрибут `disabled` для поля ввода в зависимости от переданного значения
(точнее, от того, является ли переданное значение `truthy`).
Используется `read-only` хэндлер `attr`, который позволяет добавлять или убирать
атрибуты элемента в зависимости от переданного значения
(точнее, от того, является ли переданное значение `truthy`) */
'@ui.input': 'value:c_Value,disabled:disabled,attr:{readonly:disabled}',
/* Используется `read-only` хэндлер `text`, который устанавливает содержимое
`HTMLElement` в строку `c_CaptionText` */
'@ui.caption': 'text:c_CaptionText',
},
/* Стандартное свойство `events` для `Marionette.View` */
events: {
'click @ui.caption'(e) {
/* `this.getBinding() позволяет получить текущее значение из контекста биндингов`*/
this.trigger('caption:click', this.getBinding('c_CaptionText'), e);
},
},
/* Стандартное свойство `modelEvents` для `Marionette.View` */
modelEvents: {
'request'() {
this.viewModel.set({ disabled: true });
},
'sync'() {
this.viewModel.set({ disabled: false });
},
'error'() {
this.viewModel.set({ disabled: false });
}
}
})
class CustomView extends ItemView {
initialize() {
/* Для всех `*View` из фреймворка можно задать специальную дополнительную модель для
хранения состояния самого виджета. Для этого используется специальный класс `ViewModel`.
Отличается от обычной модели `Model` тем, что всегда содержит поля `disabled`, `enabled`,
а также можно задать `parentViewModel` и тогда `disabled`, `enabled` будут учитывать
состояние родительской модели:
Если родительская модель `disabled == true`, то и сама модель `disabled === true`,
Если родительская модель `disabled == false`, то сама модель `disabled`, если ей выставить
`set({ disabled:true })`.
Удобно, когда надо выстроить иерархию блокирования виджета - блокируем родителя, и все дети
будут заблокированы. Разблокируем родителя и те дети, которые были до этого разблокированы,
будут разблокированы, а те, которые были заблокированы, останутся заблокироваными. */
this.viewModel = new ViewModel({
parentViewModel: this.options.parentViewModel || null
});
}
onDestroy() {
this.viewModel.destroy();
}
}
Более подробная документация доступна по следующим ссылкам:
Почему у сервера такое странное API (PUT, наряду с, legacy)?
Это API было разработано специально, чтобы проверить, как кандидиат справляется с доработкой стандартного механизма Backbone.sync
под нестандартное/кривое/устаревшее API
Насколько важно решить "самым правильным с точки зрения составителя способом"?
Код можно будет защищать. Единственного правильного варианта нету. Есть "ИМХО, самое правильное решение". Но в первую очередь важна внутрення логика решения, непротиворечивость и соответствие требованиям. Если кандидат предоставит свое оригинальное решение, которое объективно не хуже и/или сможет обосновать почему и зачем, то никаких проблем не будет.
Нету аналога
setCharge()
на сервере, только какие-то неудобныеincrease/decrease
, почему?
Это часть задачи, так задумано. Является проверкой умения работать с асинхронной логикой.
Можно ли доработать сервер, чтобы было удобнее решать задание?
Менять функциональную часть сервера категорически нельзя. Относитесь к нему как к удаленному ресурсу, надо которым у вас не контроля.