Создаем почтовый клиент на Ext JS с нуля. Часть 5: Выдвижное меню и Compose в Modern Toolkit

В четвёртой части мы создали базовый мобильный интерфейс Ext Mail на Modern Toolkit: NavigationView, MessagesGrid с аватарами, тулбар действий и row actions. Но для полноценной работы не хватало двух ключевых элементов: навигации по меткам и возможности создавать письма.

В пятой части мы добавим:

  • дерево меток (LabelsTree) для Modern Toolkit;

  • выдвижное меню (slide-out menu) в стиле нативных мобильных приложений;

  • плавающую кнопку Compose в правом нижнем углу;

  • форму написания письма для Modern;

  • интеграцию формы с общей бизнес-логикой.

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

🌳 Создаём дерево меток для Modern

В Classic Toolkit дерево меток было построено на Ext.tree.Panel. В Modern Toolkit для этого используется компонент Ext.list.Tree. Создаём класс ExtMail.view.labels.LabelsTree в папке modern/src/view/labels/:

javascript
Ext.define('ExtMail.view.labels.LabelsTree', {
    extend: 'Ext.list.Tree',
    alias: 'widget.labels-LabelsTree',
    defaults: {
        xtype: 'treeListitem',
        textProperty: 'combined'
    }
});

Мы указываем, что для отображения каждого элемента дерева нужно использовать поле combined. Это поле мы создали в модели Label ранее — оно объединяет название метки и количество непрочитанных сообщений, оборачивая счётчик в HTML-элемент с CSS-классом unread.

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

scss
.unread {
    font-weight: bold;
}

Этого достаточно, чтобы дерево работало и выглядело так же, как в Classic-версии.

📂 Создаём выдвижное меню (Slide-out Menu)

Для мобильного приложения привычно, когда метки скрыты в выдвижном меню слева. Создаём компонент ExtMail.view.menu.Menu, который будет панелью с деревом меток внутри:

javascript
Ext.define('ExtMail.view.menu.Menu', {
    extend: 'Ext.panel.Panel',
    alias: 'widget.menu-Menu',
    requires: [
        'ExtMail.view.labels.LabelsTree'
    ],
    defaultListenerScope: true,
    scrollable: 'vertical',
    layout: 'fit',
    items: [{
        xtype: 'labels-LabelsTree',
        style: { background: 'white' },
        bind: {
            store: '{labels}',
            selection: '{selectedLabel}'
        },
        listeners: {
            selectionchange: 'onLabelSelectionChange'
        }
    }],

    onLabelSelectionChange: function() {
        this.fireEvent('closemenu');
    },

    // Поведение меню
    getReveal: function() { return true; },
    getCover: function() { return false; },
    getSide: function() { return 'left'; }
});

Мы привязываем дерево к общему хранилищу labels и свойству selectedLabel из MainViewModel. При выборе метки генерируем событие closemenu, чтобы меню автоматически закрывалось.

Три метода — getReveal, getCover и getSide — определяют поведение меню:

  • getReveal: true — меню появляется из-под основного контента, сдвигая его вправо.

  • getCover: false — меню не накрывает контент сверху.

  • getSide: 'left' — меню выезжает слева.

🔌 Подключаем меню к приложению

Меню создаётся на уровне приложения, а не внутри Main-компонента. Добавляем метод setupMenu в Application.js:

javascript
setupMenu: function() {
    Ext.Viewport.setMenu(Ext.create('ExtMail.view.menu.Menu', {
        width: 250,
        viewModel: this.getMainView().getViewModel(),
        listeners: {
            closemenu: function() {
                Ext.Viewport.hideMenu('left');
            }
        }
    }));
}

Мы передаём меню ViewModel главного вида, чтобы оно использовало те же данные и бизнес-логику. Меню прикрепляется к Viewport, поэтому не наследует ViewModel автоматически — мы передаём её вручную.

Вызываем setupMenu в методе launch:

javascript
launch: function() {
    this.setupMenu();
}

☰ Добавляем кнопку меню в тулбар

Теперь нужно дать пользователю возможность открывать меню. Переопределяем метод buildItems в Modern-версии тулбара:

javascript
buildItems: function() {
    var items = this.callParent(arguments);

    items.unshift({
        xtype: 'button',
        iconCls: 'x-fa fa-list',
        bind: { hidden: '{selectedMessage}' },
        handler: this.onMenuButtonTap,
        scope: this
    });

    return items;
},

onMenuButtonTap: function() {
    if (Ext.Viewport.getMenus().left.isHidden()) {
        Ext.Viewport.showMenu('left');
    } else {
        Ext.Viewport.hideMenu('left');
    }
}

Мы вызываем родительский buildItems, чтобы получить стандартный набор кнопок, а затем добавляем кнопку меню в начало массива. Она видна только на экране списка писем.

🖼️ Добавляем логотип приложения

Чтобы меню выглядело законченным, добавим логотип Ext Mail в его верхнюю часть как dockedItem:

javascript
items: [{
    xtype: 'component',
    docked: 'top',
    style: {
        textAlign: 'center',
        padding: '20px 0'
    },
    html: '<img src="resources/images/ext-mail-logo.png" />'
}, {
    xtype: 'labels-LabelsTree',
    // ...
}]

➕ Плавающая кнопка Compose

Для запуска процесса написания письма добавим плавающую кнопку в правом нижнем углу. Создаём класс ExtMail.view.compose.ComposeButton:

javascript
Ext.define('ExtMail.view.compose.ComposeButton', {
    extend: 'Ext.Button',
    alias: 'widget.compose-ComposeButton',
    floated: true,
    width: 60,
    height: 60,
    cls: 'compose-button',
    iconCls: 'x-fa fa-edit'
});

Создаём SCSS-файл ComposeButton.scss:

scss
.compose-button.x-button {
    background: #d8372d;
    border-radius: 50%;
    position: absolute;
    right: 40px;
    bottom: 40px;

    .x-icon-el {
        color: #FFF;
    }
}

Кнопка круглая, красная, с иконкой карандаша, расположена в правом нижнем углу.

Инициализируем кнопку в Application.js методом setupComposeButton:

javascript
setupComposeButton: function() {
    this.composeButton = Ext.create('ExtMail.view.compose.ComposeButton', {
        hidden: false,
        handler: function() {
            this.getMainView().getController().onComposeMessage();
        },
        scope: this
    });

    this.getMainView().getViewModel().bind('{selectedMessage}',
        function(selectedMessage) {
            var setDelay = selectedMessage ? 0 : this.getMainView().getLayout().getAnimation().getDuration();
            setTimeout(Ext.bind(function() {
                this.composeButton.setHidden(selectedMessage);
            }, this), setDelay);
        }, this);
}

Мы привязываемся к свойству selectedMessage: когда пользователь открывает письмо, кнопка скрывается, когда возвращается к списку — показывается. Задержка setDelay нужна, чтобы кнопка появлялась после завершения анимации перехода.

Вызываем метод в launch:

javascript
launch: function() {
    this.setupMenu();
    this.setupComposeButton();
}

📝 Создаём общую основу формы Compose

Форма для Classic и Modern имеет общую логику: встроенную ViewModel, обработку кнопок, заполнение данных получателя. Но API полей различается, поэтому создаём базовый класс ComposeFormBase в общей папке app/:

javascript
Ext.define('ExtMail.view.compose.ComposeFormBase', {
    extend: 'Ext.form.Panel',

    viewModel: {
        data: {
            selectedRecipient: null
        }
    },

    constructor: function() {
        this.callParent(arguments);
        this.getViewModel().bind('{selectedRecipient}', this.onSelectedRecipientChange, this);
    },

    onSendClick: function() {
        this.fireEvent('send', this.getViewModel().get('messageRecord'));
    },

    onDiscardClick: function() {
        this.fireEvent('discarddraft', this.getViewModel().get('messageRecord'));
    },

    onSelectedRecipientChange: function(selectedRecipientRecord) {
        var firstName, lastName, email;

        if (selectedRecipientRecord) {
            firstName = selectedRecipientRecord.get('firstName');
            lastName = selectedRecipientRecord.get('lastName');
            email = selectedRecipientRecord.get('email');
        }

        if (this.getViewModel().get('messageRecord')) {
            this.getViewModel().get('messageRecord').set({
                firstName: firstName,
                lastName: lastName,
                email: email
            });
        }
    }
});

Теперь Classic-версия формы просто расширяет этот базовый класс, а мы создаём Modern-версию с теми же полями, но с использованием API Modern Toolkit:

javascript
Ext.define('ExtMail.view.compose.ComposeForm', {
    extend: 'ExtMail.view.compose.ComposeFormBase',
    alias: 'widget.compose-ComposeForm',
    defaultListenerScope: true,
    padding: 10,
    layout: {
        type: 'vbox',
        align: 'stretch'
    },
    items: [
        {
            xtype: 'combobox',
            placeholder: 'Recipient',
            displayField: 'email',
            valueField: 'email',
            queryMode: 'local',
            required: true,
            bind: {
                store: '{contacts}',
                selection: '{selectedRecipient}',
                value: '{messageRecord.email}'
            }
        },
        {
            xtype: 'textfield',
            placeholder: 'Subject',
            bind: {
                value: '{messageRecord.subject}'
            }
        },
        {
            xtype: 'textareafield',
            placeholder: 'Compose email',
            flex: 1,
            bind: {
                value: '{messageRecord.message}'
            }
        }
    ],
    dockedItems: [{
        xtype: 'toolbar',
        docked: 'bottom',
        margin: 0,
        items: [
            {
                xtype: 'button',
                scale: 'medium',
                text: 'Send',
                handler: 'onSendClick'
            },
            '->',
            {
                xtype: 'button',
                iconCls: 'x-fa fa-trash',
                tooltip: 'Discard',
                handler: 'onDiscardClick'
            }
        ]
    }],

    onSendClick: function() {
        if (this.validate()) {
            this.callParent(arguments);
        }
    }
});

Обратите внимание на переопределённый onSendClick: в Modern Toolkit нет конфига formBind, поэтому мы вручную проверяем валидность формы перед вызовом родительского метода.

🔗 Интегрируем форму Compose

Интеграция формы в Modern использует тот же паттерн, что и в Classic. Переопределяем showComposeWindow в Modern-контроллере:

javascript
showComposeWindow: function(messageRecord) {
    this.getViewModel().set('selectedMessage', messageRecord);
}

Обновляем обработчик onSelectedMessageChange, чтобы он открывал форму для черновиков:

javascript
onSelectedMessageChange: function(selectedMessage) {
    if (selectedMessage && !selectedMessage.get('draft')) {
        this.getView().push(this.getMessageDetailsConfig(selectedMessage));
    } else if (selectedMessage && selectedMessage.get('draft')) {
        this.getView().push(this.getComposeMessageConfig(selectedMessage));
    } else {
        this.getView().pop();
    }
}

Метод getComposeMessageConfig возвращает конфигурацию формы:

javascript
getComposeMessageConfig: function(messageRecord) {
    return {
        xtype: 'compose-ComposeForm',
        viewModel: {
            data: {
                messageRecord: messageRecord
            }
        },
        listeners: {
            send: this.onSendMessage,
            discarddraft: this.onDiscardDraftMessage,
            scope: this
        }
    };
}

Здесь мы используем уже готовые методы onSendMessage и onDiscardDraftMessage из MainControllerBase — именно та самая общая бизнес-логика, которую мы сохранили для обоих тулкитов.

💎 Заключение

В пятой части мы завершили создание мобильного интерфейса Ext Mail:

  • Создали дерево меток для Modern Toolkit.

  • Реализовали выдвижное меню в стиле нативных приложений.

  • Добавили кнопку открытия меню в тулбар.

  • Разместили логотип приложения в меню.

  • Создали плавающую кнопку Compose.

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

  • Интегрировали форму с существующей бизнес-логикой.

Теперь приложение имеет полный паритет функций между Classic и Modern Toolkit: одинаковый набор возможностей, единая модель данных и общая бизнес-логика. В следующей части мы перейдём от статических JSON-файлов к настоящему REST API с использованием RAD Server.

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