Создаем почтовый клиент на Ext JS с нуля. Часть 1: Фундамент приложения

В этой серии статей мы шаг за шагом построим полноценный почтовый клиент на Ext JS. Начиная с базовой структуры и заканчивая REST API и deep linking. Автор оригинала — Stuart Ashworth (Sencha MVP), его книга «Building an Email Client from Scratch» состоит из 7 частей. Мы адаптируем материал для русскоязычного сообщества и добавим пояснения.

В первой части мы заложим фундамент: создадим Universal-приложение, опишем модели данных, настроим хранилища и построим первые UI-компоненты — таблицу сообщений и просмотрщик письма. К концу статьи у вас будет работающий каркас, на который мы будем наращивать функциональность в следующих частях.

🎯 Что мы строим: Ext Mail

Наш почтовый клиент будет называться Ext Mail. Он позволит:

  • просматривать список писем в Grid;

  • открывать письмо и читать его полностью;

  • фильтровать по меткам (Inbox, Sent, Drafts и т.д.);

  • создавать новые письма;

  • работать на десктопе и мобильных устройствах.

Вот как будет выглядеть главный экран в Classic Toolkit:

А так — в Modern Toolkit:

🛠️ Подготовка: Sencha Cmd и Ext JS SDK

Для начала вам понадобятся:

  1. Ext JS SDK (можно скачать trial на официальном сайте Sencha).

  2. Sencha Cmd — инструмент командной строки для сборки и генерации приложений.

Создаем новое Universal-приложение одной командой:

bash
sencha generate app ExtMail ./ext-mail

Universal-приложение означает, что мы сможем собирать его как для Classic (десктоп), так и для Modern (мобильные) тулкитов, используя общий код.

📁 Структура проекта

После генерации вы получите следующую структуру:

  • app/ — общий код для обоих тулкитов (модели, хранилища, контроллеры, вьюмодели).

  • classic/src/ — код, специфичный для Classic Toolkit.

  • modern/src/ — код для Modern Toolkit.

  • data/ — статические JSON-файлы (пока не настоящий бэкенд).

  • resources/ — стили, изображения.

Загрузчик классов сначала ищет класс в папке тулкита, а если не находит — в общей папке app/.

🗃️ Данные: модели, хранилища, JSON

На первом этапе мы не будем подключать реальный API. Вместо этого создадим три JSON-файла в папке data/:

  • contacts.json — контакты (имя, email, телефон).

  • labels.json — метки в виде дерева (Inbox, Starred, Sent, Drafts и т.д.).

  • messages.json — список писем.

Модель Message (app/model/Message.js)

Описываем поля письма: отправитель, тема, дата, текст, метки, флаги (прочитано, черновик, отправлено).

javascript
Ext.define('ExtMail.model.Message', {
    extend: 'Ext.data.Model',
    fields: [
        { name: 'firstName' },
        { name: 'lastName' },
        { name: 'fullName', calculate: function(data) {
            return Ext.String.trim((data.firstName || '') + ' ' + (data.lastName || ''));
        }},
        { name: 'email' },
        { name: 'date', type: 'date', dateFormat: 'c' },
        { name: 'subject' },
        { name: 'message' },
        { name: 'labels', type: 'auto', defaultValue: [] },
        { name: 'unread', type: 'boolean' },
        { name: 'draft', type: 'boolean' },
        { name: 'outgoing', type: 'boolean' },
        { name: 'sent', type: 'boolean' }
    ],
    // Генерация UUID для новых писем
    requires: ['Ext.data.identifier.Uuid'],
    identifier: 'uuid',
    // Вспомогательные методы для работы с метками
    hasLabel: function(labelId) {
        var labels = this.get('labels') || [];
        return labels.indexOf(labelId) >= 0;
    },
    addLabel: function(labelId) {
        var labels = this.get('labels') || [];
        labels.push(labelId);
        this.set('labels', Ext.clone(labels));
    },
    removeLabel: function(labelId) {
        var labels = this.get('labels') || [];
        labels = Ext.Array.remove(labels, labelId);
        this.set('labels', Ext.clone(labels));
    }
});

Модели Contact и Label создаются аналогично. Label расширяет Ext.data.TreeModel, так как метки образуют дерево.

Хранилища (app/store/)

Создаем три хранилища:

  • Messages — для писем, с сортировкой по дате (DESC) и AJAX-прокси на messages.json.

  • Contacts — для контактов.

  • Labels — на базе Ext.data.TreeStore для древовидной структуры меток.

Пример Messages:

javascript
Ext.define('ExtMail.store.Messages', {
    extend: 'Ext.data.Store',
    alias: 'store.Messages',
    model: 'ExtMail.model.Message',
    autoLoad: true,
    sorters: [{ property: 'date', direction: 'DESC' }],
    proxy: {
        type: 'ajax',
        url: 'data/messages.json',
        reader: {
            type: 'json',
            rootProperty: 'rows'
        }
    }
});

🧠 Общие ViewController и ViewModel

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

  • MainControllerBase — общий контроллер.

  • MainModel — общая вьюмодель (состояние приложения).

В тулкит-специфичных папках создаем наследников:

  • classic/src/view/main/MainController.js — расширяет MainControllerBase.

  • modern/src/view/main/MainController.js — аналогично.

В MainModel определяем данные и хранилища:

javascript
Ext.define('ExtMail.view.main.MainModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.main',
    data: {
        selectedMessage: null
    },
    formulas: {
        messageCardIndex: function(get) {
            return get('selectedMessage') ? 1 : 0;
        }
    },
    stores: {
        messages: {
            type: 'Messages'
        }
    }
});

Здесь selectedMessage — текущее выбранное письмо, а формула messageCardIndex вычисляет, какую карточку показывать (0 — Grid, 1 — Reader).

🖥️ Создаем MessageGrid

Класс ExtMail.view.messages.MessageGrid расширяет Ext.grid.Panel. Определяем колонки: полное имя, тема, дата. Привязываем store к вьюмодели.

javascript
Ext.define('ExtMail.view.messages.MessageGrid', {
    extend: 'Ext.grid.Panel',
    alias: 'widget.messages-MessageGrid',
    cls: 'message-grid',
    columns: [
        { dataIndex: 'fullName', minWidth: 250, header: false, tdCls: 'full-name' },
        { dataIndex: 'subject', flex: 1, header: false },
        { xtype: 'datecolumn', dataIndex: 'date', header: 'Received', width: 100, align: 'end', format: 'j M \'y' }
    ],
    viewConfig: {
        getRowClass: function(record) {
            return record.get('unread') ? 'unread' : '';
        }
    }
});

Стилизуем непрочитанные письма жирным шрифтом через SCSS-файл MessageGrid.scss.

📖 Создаем MessageReader

Компонент для отображения полного текста письма. Используем Ext.XTemplate для вывода данных.

javascript
Ext.define('ExtMail.view.reader.MessageReader', {
    extend: 'Ext.panel.Panel',
    alias: 'widget.reader-MessageReader',
    cls: 'message-reader',
    tpl: [
        '<div class="subject">{subject}</div>',
        '<div class="info">',
            '<div class="sender">',
                '<span class="name">{fullName}</span>',
                '<span class="email">({email})</span>',
            '</div>',
            '<div class="date">{date:date("D, j M Y, H:i")}</div>',
        '</div>',
        '<div class="message">{message}</div>'
    ]
});

🔄 Навигация между Grid и Reader

В главном компоненте Main используем Card layout для переключения между списком и просмотром. Оборачиваем оба компонента в панель с layout: 'card', привязываем activeItem к формуле messageCardIndex.

javascript
{
    xtype: 'panel',
    region: 'center',
    layout: 'card',
    reference: 'messagesWrapper',
    bind: {
        activeItem: '{messageCardIndex}'
    },
    items: [
        {
            xtype: 'messages-MessageGrid',
            bind: { store: '{messages}' },
            listeners: {
                itemclick: 'onMessageClick'
            }
        },
        {
            xtype: 'reader-MessageReader',
            bind: { data: '{selectedMessage}' }
        }
    ]
}

В контроллере обрабатываем клик по строке:

javascript
onMessageClick: function(grid, record) {
    this.getViewModel().set('selectedMessage', record);
}

🧰 Динамический Toolbar

Создаем панель инструментов MessagesToolbar, которая меняет содержимое в зависимости от контекста. На Grid показываем кнопку «Обновить», на Reader — «Назад».

javascript
Ext.define('ExtMail.view.messages.MessagesToolbar', {
    extend: 'Ext.toolbar.Toolbar',
    alias: 'widget.messages-MessagesToolbar',
    defaultListenerScope: true,
    items: [
        {
            tooltip: 'Refresh',
            iconCls: 'x-fa fa-redo',
            handler: 'onRefreshClick',
            bind: { hidden: '{selectedMessage}' }
        },
        {
            tooltip: 'Back',
            iconCls: 'x-fa fa-arrow-left',
            handler: 'onBackClick',
            bind: { hidden: '{!selectedMessage}' }
        }
    ],
    onRefreshClick: function() {
        this.fireEvent('refresh');
    },
    onBackClick: function() {
        this.fireEvent('back');
    }
});

В Main добавляем тулбар как dockedItem и подписываемся на его события:

javascript
dockedItems: [{
    xtype: 'messages-MessagesToolbar',
    dock: 'top',
    listeners: {
        refresh: 'onRefreshMessages',
        back: 'onBackToMessagesGrid'
    }
}]

Методы в контроллере:

javascript
onRefreshMessages: function() {
    this.getViewModel().getStore('messages').reload();
},
onBackToMessagesGrid: function() {
    this.getViewModel().set('selectedMessage', null);
}

💎 Заключение

В первой части мы создали фундамент почтового клиента Ext Mail:

  • Настроили Universal-приложение и структуру папок.

  • Описали модели Message, Contact, Label.

  • Создали хранилища Messages, Contacts, Labels.

  • Построили общие ViewController и ViewModel.

  • Реализовали Grid для списка писем и Reader для просмотра.

  • Настроили навигацию через Card layout и динамический Toolbar.

В следующей части мы добавим метки, дерево и динамические действия: фильтрацию по меткам, отображение количества непрочитанных сообщений и расширенный тулбар. Следите за обновлениями!

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