Создаем почтовый клиент на Ext JS с нуля. Часть 4: Мобильная версия на Modern Toolkit

В первых трёх частях мы построили полноценный почтовый клиент Ext Mail для десктопа на Classic Toolkit: создали Universal-приложение, модели и хранилища, Grid и Reader, дерево меток, динамический тулбар, форму создания письма и работу с черновиками. Однако современное корпоративное приложение должно одинаково хорошо работать и на мобильных устройствах.

В четвёртой части мы интегрируем Modern Toolkit и создадим мобильную версию интерфейса, максимально переиспользуя уже написанную бизнес-логику. Это одно из главных преимуществ Universal-приложений на Ext JS: общий код живёт в папке app/, а специфичные для тулкита компоненты — в classic/ и modern/.

Все примеры кода взяты из оригинальной книги Stuart Ashworth (Sencha MVP) и адаптированы для русскоязычного читателя.

🚀 Запуск Modern Toolkit

Чтобы запустить приложение в режиме Modern Toolkit, используйте команду:

bash
sencha app watch modern

Затем откройте приложение в браузере и включите режим эмуляции мобильного устройства (например, в Chrome DevTools). Sencha Cmd автоматически подставит Modern-версию приложения.

🏗️ Создаём Main View для Modern

В Modern Toolkit корневой компонент приложения должен быть другим. Мы создадим класс ExtMail.view.main.Main, который расширяет Ext.NavigationView. Этот компонент даёт «нативный» вид и функциональность: заголовок, кнопку «Назад», анимацию скольжения при переходе между экранами.

javascript
Ext.define('ExtMail.view.main.Main', {
    extend: 'Ext.NavigationView',
    xtype: 'app-main',
    fullscreen: true,
    requires: [
        'ExtMail.view.main.MainController',
        'ExtMail.view.main.MainModel'
    ],
    controller: 'main',
    viewModel: 'main'
});

NavigationView позволяет «нажимать» (push) и «выталкивать» (pop) дочерние компоненты, обеспечивая навигацию в стиле мобильных приложений. Он автоматически отображает заголовок текущей карточки и кнопку «Назад».

Контроллер MainController для Modern расширяет общий MainControllerBase из папки app/:

javascript
Ext.define('ExtMail.view.main.MainController', {
    extend: 'ExtMail.view.main.MainControllerBase',
    alias: 'controller.main'
});

Благодаря этому вся бизнес-логика (обработка писем, действия с метками, отправка черновиков) остаётся общей и не дублируется.

📱 Создаём Messages Grid для Modern

Для отображения списка писем создаём компонент ExtMail.view.messages.MessagesGrid, который расширяет Ext.grid.Grid (аналог Ext.grid.Panel в Modern).

javascript
Ext.define('ExtMail.view.messages.MessagesGrid', {
    extend: 'Ext.grid.Grid',
    alias: 'widget.messages-MessagesGrid',
    cls: 'messages-grid',
    // ...
});

Колонки

Мы создаём две колонки:

  1. Аватар — цветной кружок с первой буквой имени отправителя.

  2. Информация о письме — полное имя, тема, фрагмент текста и дата.

javascript
columns: [
    {
        dataIndex: 'firstName',
        width: 60,
        cell: {
            encodeHtml: false
        },
        tpl: [
            '<div class="avatar" style="background-color: {[this.getAvatarColour(values.firstName)]};">',
                '<span>{[values.firstName.substring(0, 1).toUpperCase()]}</span>',
            '</div>'
        ].join(''),
        getAvatarColour: function(name) {
            var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
            var colours = ['#e6194B', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#a9a9a9'];
            var index = alphabet.indexOf(name.substring(0, 1).toUpperCase());
            return colours[index % colours.length];
        }
    },
    {
        dataIndex: 'subject',
        flex: 1,
        cell: {
            encodeHtml: false
        },
        tpl: [
            '<div class="{[values.unread ? \'unread\' : \'\']}">',
                '<div class="top-line">',
                    '<span class="name">{fullName}</span>',
                    '<span class="date">{date:date("j M \'y")}</span>',
                '</div>',
                '<div class="subject">{subject}</div>',
                '<div class="message">{message}</div>',
            '</div>'
        ].join('')
    }
]

Шаблон первой колонки использует функцию getAvatarColour, которая по первой букве имени выбирает цвет из палитры. Вторая колонка выводит имя, дату, тему и фрагмент сообщения. Условие {values.unread ? 'unread' : ''} добавляет CSS-класс для непрочитанных писем.

Стили

Создаём SCSS-файл MessagesGrid.scss рядом с компонентом:

scss
.messages-grid {
    .avatar {
        border-radius: 50%;
        display: flex;
        align-items: center;
        justify-content: center;
        width: 35px;
        height: 35px;
        color: #fff;
        font-weight: bold;
    }
    .unread {
        .name, .date, .subject {
            font-weight: bold;
        }
    }
    .top-line {
        display: flex;
    }
    .name {
        font-size: 1.05rem;
        flex: 1;
    }
    .subject, .message {
        text-overflow: ellipsis;
        overflow: hidden;
    }
}

Sencha Cmd автоматически скомпилирует этот файл и добавит стили в приложение.

Интеграция в Main

Добавляем Grid в Main и привязываем к хранилищу messages:

javascript
items: [{
    xtype: 'messages-MessagesGrid',
    hideHeaders: true,
    titleBar: false,
    bind: {
        store: '{messages}'
    },
    listeners: {
        childtap: 'onMessageTap'
    }
}]

Событие childtap в Modern Toolkit аналогично itemclick в Classic. Оно передаёт объект location, содержащий запись.

👆 Обработка тапа по сообщению

В Classic Toolkit при клике на письмо мы устанавливали свойство selectedMessage во ViewModel и переключали карточку. В Modern Toolkit мы хотим использовать ту же логику, но с учётом навигации.

Для этого вынесем общий код в MainControllerBase:

javascript
// app/view/main/MainControllerBase.js
handleMessageClick: function(messageRecord) {
    if (messageRecord.get('draft')) {
        this.showComposeWindow(messageRecord);
    } else {
        this.getViewModel().set('selectedMessage', messageRecord);
    }
}

Теперь в Modern-контроллере обрабатываем childtap:

javascript
// modern/src/view/main/MainController.js
onMessageTap: function(grid, location) {
    this.handleMessageClick(location.record);
}

А в Classic-контроллере обновляем onMessageClick:

javascript
// classic/src/view/main/MainController.js
onMessageClick: function(grid, messageRecord, rowEl, index, e) {
    if (e.getTarget('.x-action-col-icon')) {
        return;
    }
    this.handleMessageClick(messageRecord);
}

📖 Отображение MessageReader в Modern

Чтобы при выборе письма открывался экран чтения, используем возможности NavigationView. В MainController для Modern добавляем метод init, который подписывается на изменение selectedMessage:

javascript
init: function() {
    this.getViewModel().bind('{selectedMessage}', this.onSelectedMessageChange, this);
},

onSelectedMessageChange: function(selectedMessage) {
    if (selectedMessage) {
        this.getView().push(this.getMessageDetailsConfig(selectedMessage));
    } else {
        this.getView().pop();
    }
},

getMessageDetailsConfig: function(messageRecord) {
    return {
        xtype: 'reader-MessageReader',
        data: messageRecord.data,
        header: false
    };
}

Когда selectedMessage становится truthy, мы делаем push нового компонента MessageReader, иначе — pop (возврат к списку).

Компонент MessageReader изначально лежал в папке classic/. Поскольку он не использует ничего специфичного для Classic, мы можем переместить его в общую папку app/view/reader/. После этого он станет доступен обоим тулкитам.

🧰 Добавляем тулбар действий

В Classic Toolkit у нас был тулбар с кнопками Refresh, Back, Archive, Delete, Mark as Unread. Мы хотим такой же в Modern, но с учётом особенностей тулкита.

Создаём общий базовый класс MessagesToolbarBase в папке app/:

javascript
Ext.define('ExtMail.view.messages.MessagesToolbarBase', {
    extend: 'Ext.Toolbar',
    buildItems: function() {
        return [
            {
                xtype: 'button',
                tooltip: 'Refresh',
                iconCls: 'x-fa fa-redo',
                handler: this.makeHandler('refresh'),
                scope: this,
                bind: { hidden: '{!visibleMessageButtons.refresh}' }
            },
            {
                xtype: 'button',
                tooltip: 'Back',
                iconCls: 'x-fa fa-arrow-left',
                handler: this.makeHandler('back'),
                scope: this,
                hidden: true,
                bind: { hidden: '{!visibleMessageButtons.back}' }
            },
            // ... Archive, Delete, Mark as Unread
            {
                xtype: 'component',
                tpl: '{count} messages',
                data: {},
                bind: {
                    hidden: '{!visibleMessageButtons.messageCount}',
                    data: { count: '{messages.count}' }
                }
            }
        ];
    },
    makeHandler: function(event) {
        return function() {
            this.fireEvent(event);
        };
    }
});

Мы используем bind: { hidden: '{!visibleMessageButtons.refresh}' } — это привязка к вычисляемым свойствам, которые мы определим во ViewModel. Логика видимости кнопок остаётся общей.

В Classic тулбар расширяет этот базовый класс и в initComponent вызывает this.setItems(this.buildItems()). В Modern используется метод initialize:

javascript
Ext.define('ExtMail.view.messages.MessagesToolbar', {
    extend: 'ExtMail.view.messages.MessagesToolbarBase',
    alias: 'widget.messages-MessagesToolbar',
    initialize: function() {
        this.setItems(this.buildItems());
        this.callParent(arguments);
    }
});

Добавляем тулбар в Main как docked: 'top':

javascript
{
    xtype: 'messages-MessagesToolbar',
    docked: 'top',
    listeners: {
        refresh: 'onRefreshMessages',
        back: 'onBackToMessagesGrid',
        delete: 'onDeleteMessage',
        markunread: 'onMarkMessageUnread',
        archive: 'onArchiveMessage'
    }
}

Все обработчики уже есть в MainControllerBase и работают с данными, поэтому не требуют изменений.

⭐ Добавляем row action (звёздочка)

В Modern Toolkit для добавления действий в строку используется конфигурация tools у колонки. Мы создаём две «кнопки»: Star и Unstar.

javascript
{
    dataIndex: 'subject',
    flex: 1,
    cell: {
        encodeHtml: false,
        tools: {
            star: {
                handler: 'onUnStarMessage',
                iconCls: 'x-fa fa-star',
                zone: 'end',
                bind: { hidden: '{!record.starred}' }
            },
            unstar: {
                handler: 'onStarMessage',
                iconCls: 'x-fa fa-star-half-alt',
                zone: 'end',
                bind: { hidden: '{record.starred}' }
            }
        }
    }
}

Важно: чтобы привязка record.starred работала, нужно добавить в конфигурацию Grid:

javascript
itemConfig: {
    viewModel: true
}

Это говорит о том, что каждая ячейка должна иметь собственную ViewModel, привязанную к записи.

Также добавляем defaultListenerScope: true в Grid, чтобы обработчики onStarMessage и onUnStarMessage искались в самом компоненте.

javascript
onStarMessage: function(grid, info) {
    this.fireEvent('starmessage', info.record);
},

onUnStarMessage: function(grid, info) {
    this.fireEvent('unstarmessage', info.record);
}

В Main подписываемся на эти события:

javascript
listeners: {
    childtap: 'onMessageTap',
    starmessage: 'onStarMessage',
    unstarmessage: 'onUnStarMessage'
}

Обработчики onStarMessage и onUnStarMessage уже есть в MainControllerBase и работают с моделью.

💎 Заключение

В четвёртой части мы создали мобильную версию почтового клиента Ext Mail на Modern Toolkit:

  • Настроили Ext.NavigationView как корневой компонент.

  • Создали адаптированный MessagesGrid с аватарами и условным форматированием.

  • Реализовали обработку тапа и навигацию через push/pop.

  • Переиспользовали общий код благодаря рефакторингу.

  • Добавили тулбар действий и row actions (звёздочка).

Теперь приложение работает и на десктопе, и на мобильных устройствах, сохраняя единую бизнес-логику. В следующей части мы добавим выдвижное меню (sliding menu) и завершим создание интерфейса для написания писем в Modern Toolkit.

Оставьте комментарий