6 October 2015

Нюансы MVVM в Ext JS при разработке компонентов

JavaScriptExtJS/Sencha
Всем привет. Прошло немало времени с момента выхода Ext JS 5, где представили возможность разработки приложений с использованием паттерна MVVM. За это время я успел столкнуться с некоторыми трудностями, о которых хотел бы рассказать.

Начну с того, что в Ext JS 4 (а предварительно в Sencha Touch) при создании компонентов их конфигурационные свойства объявлялись в объекте config, для каждого из которых автоматически создавался свой getter и setter. Несмотря на то, что вручную писать все обработчики могло быть несколько утомительно, это был стандартный подход.

В пятой же версии Ext JS используя MVVM можно было легко избавиться от доброй части рутины: удалить конфигурационные свойства и их обработчики, а вместо этого привязаться к нужному свойству или формуле ViewModel'и. Кода становилось значительно меньше, а читаемость — лучше.

Но меня беспокоил вопрос инкапсуляции. Что если в процессе разработки часть функциональности я захочу вынести в отдельный компонент для повторного использования? Нужно ли при этом создавать собственную ViewModel? Как изменять состояние компонента: обращаться напрямую к его ViewModel'и или всё-таки стоит использовать конфигурационные свойства и их публичные setter'ы?

Мысли об этом и других вопросах, а также примеры с напильником — под катом.

Часть 1. Используем ViewModel


Давайте попробуем создать, к примеру, таблицу каких-нибудь пользователей. Чтобы она могла добавлять и удалять записи, но при необходимости переходить в режим только чтения. Также я хочу, чтобы кнопка удаления содержала имя выделенного пользователя.

Пример 1. Стандартный подход


Как бы мы это сделали без использования MVVM?



Посмотреть в Sencha Fiddle

Fiddle.view.UsersGrid
Ext.define('Fiddle.view.UsersGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'usersgrid',
    
    config: {
        /**
        @cfg {Boolean} Read only mode
        */
        readOnly: null
    },
    
    defaultListenerScope: true,
    
    tbar: [{
        text: 'Add',
        itemId: 'addButton'
    }, {
        text: 'Remove',
        itemId: 'removeButton'
    }],
    
    columns: [{
        dataIndex: 'id',
        header: 'id'
    }, {
        dataIndex: 'name',
        header: 'name'
    }],
    
    listeners: {
        selectionchange: 'grid_selectionchange'
    },
    
    updateReadOnly: function (readOnly) {        
        this.down('#addButton').setDisabled(readOnly);
        this.down('#removeButton').setDisabled(readOnly);
    },
           
    grid_selectionchange: function (self, selected) {
        var rec = selected[0];
        if (rec) {
        	this.down('#removeButton').setText('Remove ' + rec.get('name'));
        }
    }
});


Установка режима Read only
readOnlyButton_click: function (self) {
    this.down('usersgrid').setReadOnly(self.pressed);
}


Довольно многословно, зато понятно: вся логика работы компонента находится внутри. Нужно оговориться, что можно использовать ViewController'ы, и это тоже будет считаться частью компонента, но в примерах я обойдусь без них.

Пример 2. Добавляем MVVM


Уберём обработчики кода и заменим их привязками (bind).

Посмотреть в Sencha Fiddle

Fiddle.view.UsersGrid
Ext.define('Fiddle.view.UsersGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'usersgrid',
    
    reference: 'usersgrid',    
    
    viewModel: {
        data: {
            readOnly: false
        }
    },
    
    tbar: [{
        text: 'Add',
        itemId: 'addButton',
        bind: {
            disabled: '{readOnly}'
        }
    }, {
        text: 'Remove',
        itemId: 'removeButton',
        bind: {
            disabled: '{readOnly}',
            text: 'Remove {usersgrid.selection.name}'
        }
    }],
    
    columns: [{
        dataIndex: 'id',
        header: 'id'
    }, {
        dataIndex: 'name',
        header: 'name'
    }]
});


Установка режима Read only
readOnlyButton_click: function (self) {
    this.down('usersgrid').getViewModel().set('readOnly', self.pressed);
}


Выглядит значительно лучше, правда? Особенно, если представить, что входных параметров кроме readOnly может быть гораздо больше — тогда разница будет колоссальной.

Сравнивая эти примеры, напрашиваются некоторые вопросы:

Вопрос 1. Где мы должны были создавать ViewModel? Можно ли было описать её во внешнем контейнере?

— С одной стороны, можно, но тогда мы получаем сильную связанность: каждый раз, когда мы переносим этот компонент в другое место, мы будем обязаны не забыть добавить свойство readOnly во ViewModel'и нового контейнера. Так легко ошибиться и вообще родительский контейнер не должен знать о внутренностях компонентов, которые в него добавляются.

Вопрос 2. Что такое reference? Почему мы прописали его внутри компонента?

— Reference — это аналог id компонента во ViewModel'и. Мы прописали его потому что для кнопки Remove стоит привязка к имени выделенного пользователя, а без указания reference это работать не будет.

Вопрос 3. А правильно ли так делать? Что если я захочу добавить два экземпляра в один контейнер — у них будет один reference?

— Да, и это конечно же неправильно. Нужно подумать, как это решить.

Вопрос 4. Правильно ли обращаться к ViewModel'и компонента извне?

— Вообще, работать оно будет, но это опять обращение к внутренностям компонента. Меня, по идее, не должно интересовать, есть у него ViewModel или нет. Если я хочу изменить его состояние, то я должен вызвать соответствующий setter как это и было когда-то задумано.

Вопрос 5. Можно ли всё-таки использовать конфигурационные свойства, и при этом привязываться к их значениям? Ведь в документации для этого случая есть свойство publishes?

— Можно и это хорошая идея. Кроме, конечно, проблемы с явным указанием reference в привязке. Установка режима readOnly в данном случае будет такой же, как и в Примере 1 — через публичный setter:

Пример 3. Fiddle.view.UsersGrid
Ext.define('Fiddle.view.UsersGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'usersgrid',
    
    reference: 'usersgrid',
    
    viewModel: {
        
    },
    
    config: {
        readOnly: false
    },
    
    publishes: ['readOnly'],
    
    tbar: [{
        text: 'Add',
        itemId: 'addButton',
        bind: {
            disabled: '{usersgrid.readOnly}'
        }
    }, {
        text: 'Remove',
        itemId: 'removeButton',
        bind: {
            disabled: '{usersgrid.readOnly}',
            text: 'Remove {usersgrid.selection.name}'
        }
    }],
    
    columns: [{
        dataIndex: 'id',
        header: 'id'
    }, {
        dataIndex: 'name',
        header: 'name'
    }]
});


Посмотреть в Sencha Fiddle

Кое-что ещё


Это касается последнего вопроса. Если мы привяжемся из внешнего контейнера на свойство внутреннего компонента (например, на выделенную строку таблицы) — привязка работать не будет (пруф). Это случается как только у внутреннего компонента появляется своя ViewModel — изменения свойств публикуются только в неё (а если точнее, то в первую по иерархии). На официальном форуме этот вопрос поднимался несколько раз — и пока тишина, есть лишь только зарегистрированный реквест (EXTJS-15503). Т.е, если взглянуть на картинку из КДПВ с этой точки зрения, то получается вот что:



Т.е. контейнер 1 может привязаться ко всем внутренним компонентам, кроме контейнера 2. Тот в свою очередь так же, кроме контейнера 3. Потому что все компоненты публикуют изменения свойств только в первую по иерархии ViewModel, начиная со своей.

Слишком много информации? Попробуем разобраться.




Часть 2. За работу!




ПРЕДУПРЕЖДЕНИЕ. Решения, описанные ниже, носят статус экспериментальных. Используйте их с осторожностью, потому что обратная совместимость гарантируется не во всех случаях. Замечания, исправления и другая помощь приветствуются. Поехали!

Итак, для начала я бы хотел сформулировать своё видение разработки компонентов с MVVM:

  1. Для изменения состояния компонента использовать конфигурационные свойства и их публичные setter'ы.
  2. Иметь возможность привязываться к собственным конфигурационным свойствам (внутри компонента).
  3. Иметь возможность привязываться к свойствам компонента снаружи вне зависимости, есть у него своя ViewModel или нет.
  4. Не задумываться об уникальности имён внутри иерархии данных ViewModel'ей.


Фикс №1. Публикуем изменения вверх


Начнём с чего-нибудь попроще, например, с пункта 3. Здесь дело в классе-примеси Ext.mixin.Bindable и его методе publishState. Если заглянуть внутрь, то мы увидим, что изменения публикуются во ViewModel, которая находится первой по иерархии. Давайте сделаем так, чтобы родительская ViewModel об этом тоже знала:

publishState: function (property, value) {
    var me = this,
        vm = me.lookupViewModel(),
        parentVm = me.lookupViewModel(true),
        path = me.viewModelKey;

    if (path && property && parentVm) {
        path += '.' + property;
        parentVm.set(path, value);
    }

    Ext.mixin.Bindable.prototype.publishState.apply(me, arguments);
}


До После

Демо на Sencha Fiddle.

Фикс №2. Привязываемся к собственным конфигурационным свойствам


Касаемо пункта 2. Кажется несправедливым, что снаружи есть возможность привязаться к свойствам компонента, а изнутри — нет. Вернее, с указанием reference
— можно, но раз мы решили, что это не очень красивый вариант, то как минимум вручную можем сделать лучше:

Fiddle.view.UsersGrid
Ext.define('Fiddle.view.UsersGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'usersgrid',
    
    viewModel: {
        data: {
            readOnly: false,
            selection: null
        }
    },
    
    config: {
        readOnly: false
    },
    
    tbar: [{
        text: 'Add',
        itemId: 'addButton',
        bind: {
            disabled: '{readOnly}'
        }
    }, {
        text: 'Remove',
        itemId: 'removeButton',
        bind: {
            disabled: '{readOnly}',
            text: 'Remove {selection.name}'
        }
    }],
    
    // ...
    
    updateReadOnly: function (readOnly) {
        this.getViewModel().set('readOnly', readOnly);
    },
    
    updateSelection: function (selection) {
        this.getViewModel().set('selection', selection);
    }
});


Демо на Sencha Fiddle

Выглядит лучше, правда? Снаружи привязываемся с указанием reference, а изнутри — без. Теперь каким бы он ни был, код компонента не меняется. Более того, теперь мы можем добавить два компонента в один контейнер, дать им свои названия reference
— и всё будет работать!

Автоматизируем? Добавим к предыдущему методу publishState:

if (property && vm && vm.getView() == me) {
    vm.set(property, value);
}

Вот и всё. Оцените, насколько лаконичными стали привязки к своим конфигурационным свойствам:

Fiddle.view.UsersGrid
Ext.define('Fiddle.view.UsersGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'usersgrid',
    
    viewModel: {
        
    },
    
    config: {
        readOnly: false
    },
    
    publishes: ['readOnly'],
    
    tbar: [{
        text: 'Add',
        itemId: 'addButton',
        bind: {
            disabled: '{readOnly}'
        }
    }, {
        text: 'Remove',
        itemId: 'removeButton',
        bind: {
            disabled: '{readOnly}',
            text: 'Remove {selection.name}'
        }
    }],
    
    columns: [{
        dataIndex: 'id',
        header: 'id'
    }, {
        dataIndex: 'name',
        header: 'name'
    }]
});


Ext.ux.mixin.Bindable
/* global Ext */

/**
 * An override to notify parent ViewModel about current component's published properties changes
 * and to make own ViewModel contain current component's published properties values.
 */
Ext.define('Ext.ux.mixin.Bindable', {
    initBindable: function () {
        var me = this;
        Ext.mixin.Bindable.prototype.initBindable.apply(me, arguments);
        me.publishInitialState();
    },

    /**
    Notifying both own and parent ViewModels about state changes
    */
    publishState: function (property, value) {
        var me = this,
            vm = me.lookupViewModel(),
            parentVm = me.lookupViewModel(true),
            path = me.viewModelKey;

        if (path && property && parentVm) {
            path += '.' + property;
            parentVm.set(path, value);
        }

        Ext.mixin.Bindable.prototype.publishState.apply(me, arguments);

        if (property && vm && vm.getView() == me) {
            vm.set(property, value);
        }
    },

    /**
    Publish initial state
    */
    publishInitialState: function () {
        var me = this,
            state = me.publishedState || (me.publishedState = {}),
            publishes = me.getPublishes(),
            name;

        for (name in publishes) {
            if (state[name] === undefined) {
                me.publishState(name, me[name]);
            }
        }
    }
}, function () {
    Ext.Array.each([Ext.Component, Ext.Widget], function (Class) {
        Class.prototype.initBindable = Ext.ux.mixin.Bindable.prototype.initBindable;
        Class.prototype.publishState = Ext.ux.mixin.Bindable.prototype.publishState;
        Class.mixin([Ext.ux.mixin.Bindable]);
    });
});


Демо на Sencha Fiddle.

Фикс №3. Разделяем ViewModel'и компонентов


Самое сложное: пункт 4. Для чистоты эксперимента предыдущие фиксы не используем. Дано: два вложенных компонента с одинаковым конфигурационным свойвтвом — color. Каждый использует ViewModel для привязки к этому значению. Требуется: привязать свойство внутреннего компонента к свойству внешнего. Попробуем?

Fiddle.view.OuterContainer
Ext.define('Fiddle.view.OuterContainer', {
    // ...    
    viewModel: {
        data: {
            color: null
        }
    },
    
    config: {
        color: null
    },
    
    items: [{
        xtype: 'textfield',
        fieldLabel: 'Enter color',
        listeners: {
            change: 'colorField_change'
        }
    }, {
        xtype: 'displayfield',
        fieldLabel: 'Color',
        bind: '{color}'
    }, {
        xtype: 'innercontainer',
        bind: {
            color: '{color}'
        }
    }],
    
    colorField_change: function (field, value) {
        this.setColor(value);
    },
    
    updateColor: function (color) {
        this.getViewModel().set('color', color);
    }
})


Fiddle.view.InnerContainer
Ext.define('Fiddle.view.InnerContainer', {
    // ...
    viewModel: {
        data: {
            color: null
        }
    },
    
    config: {
        color: null
    },
    
    items: [{
        xtype: 'displayfield',
        fieldLabel: 'Color',
        bind: '{color}'
    }],
    
    updateColor: function (color) {
        this.getViewModel().set('color', color);
    }
})


Демо на Sencha Fiddle.



Выглядит просто, но не работает. Почему? Потому что если внимательно приглядеться, то следующие формы записи абсолютно идентичны:

Вариант 1.
Ext.define('Fiddle.view.OuterContainer', {
    // ...   
    viewModel: {
        data: {
            color: null
        }
    },
    
    items: [{
        xtype: 'innercontainer',
        bind: {
            color: '{color}'
        }
    }]
    // ...
})

Ext.define('Fiddle.view.InnerContainer', {
    // ...
    viewModel: {
        data: {
            color: null
        }
    },
    
    config: {
        color: null
    },
    
    items: [{
        xtype: 'displayfield',
        fieldLabel: 'Color',
        bind: '{color}'
    }]
    // ...
})



Вариант 2.
Ext.define('Fiddle.view.OuterContainer', {
    // ...   
    viewModel: {
        data: {
            color: null
        }
    },
    
    items: [{
        xtype: 'innercontainer'        
    }]
    // ...
})

Ext.define('Fiddle.view.InnerContainer', {
    // ...
    viewModel: {
        data: {
            color: null
        }
    },
    
    config: {
        color: null
    },

    bind: {
        color: '{color}'
    },
    
    items: [{
        xtype: 'displayfield',
        fieldLabel: 'Color',
        bind: '{color}'
    }]
    // ...
})



Внимание, вопрос! К свойству color чьей ViewModel'и мы биндимся во внутреннем контейнере? Как ни странно, в обоих случаях — к внутренней. При этом, судя по документации и картинке из шапки, данные ViewModel'и внешнего контейнера являются прототипом для данных ViewModel'и внутреннего. А т.к. у последнего переопределено значение color, то при изменении значения у прототипа, у наследника оно остаётся старым (null). Т.е. в принципе, глюка нет — так и должно быть.

Как можно выйти из ситуации? Самое очевидное — убрать color из внутренней ViewModel'и. Тогда нам также придётся убрать обработчик updateColor. И конфигурационное свойство — тоже в топку! Будем надеяться, что в родительском контейнере всегда будет ViewModel со свойством color.

Или нет? Надежда — это не то, с чем мы имеем дело. Другой вариант — это переназвать все конфигурационные свойства (и поля ViewModel'и) так, чтобы не было дублирования (в теории): outerContainerColor и innerContainerColor. Но это тоже ненадёжно. В больших проектах столько имён, да и вообще не очень красиво получается.

Вот было бы здорово, описывая внешний контейнер, указывать привязку как-нибудь так:

Ext.define('Fiddle.view.OuterContainer', {
    viewModel: {
        data: {
            color: null
        }
    },
    
    items: [{
        xtype: 'innercontainer',
        bind: {
            color: '{outercontainer.color}' // с префиксом
        }
    }]
})


Не буду томить, это тоже можно сделать:

Ext.ux.app.SplitViewModel + Ext.ux.app.bind.Template
/**
An override to split ViewModels data by their instances
*/
Ext.define('Ext.ux.app.SplitViewModel', {
    override: 'Ext.app.ViewModel',

    config: {
        /**
        @cfg {String}
        ViewModel name
        */
        name: undefined,

        /**
        @cfg {String}
        @private
        name + sequential identifer
        */
        uniqueName: undefined,

        /**
        @cfg {String}
        @private
        uniqueName + nameDelimiter
        */
        prefix: undefined
    },

    nameDelimiter: '|',
    expressionRe: /^(?:\{[!]?(?:(\d+)|([a-z_][\w\-\.|]*))\})$/i,
    uniqueNameRe: /-\d+$/,

    privates: {
        applyData: function (newData, data) {
            newData = this.getPrefixedData(newData);
            data = this.getPrefixedData(data);

            return this.callParent([newData, data]);
        },

        applyLinks: function (links) {
            links = this.getPrefixedData(links);
            return this.callParent([links]);
        },

        applyFormulas: function (formulas) {
            formulas = this.getPrefixedData(formulas);
            return this.callParent([formulas]);
        },

        bindExpression: function (path, callback, scope, options) {
            path = this.getPrefixedPath(path);
            return this.callParent([path, callback, scope, options]);
        }
    },

    bind: function (descriptor, callback, scope, options) {
        if (Ext.isString(descriptor)) {
            descriptor = this.getPrefixedDescriptor(descriptor);
        }
        return this.callParent([descriptor, callback, scope, options]);
    },

    linkTo: function (key, reference) {
        key = this.getPrefixedPath(key);
        return this.callParent([key, reference]);
    },

    get: function (path) {
        path = this.getPrefixedPath(path);
        return this.callParent([path]);
    },

    set: function (path, value) {
        if (Ext.isString(path)) {
            path = this.getPrefixedPath(path);
        }
        else if (Ext.isObject(path)) {
            path = this.getPrefixedData(path);
        }
        this.callParent([path, value]);
    },

    applyName: function (name) {
        name = name || this.type || 'viewmodel';
        return name;
    },

    applyUniqueName: function (id) {
        id = id || Ext.id(null, this.getName() + '-');
        return id;
    },

    applyPrefix: function (prefix) {
        prefix = prefix || this.getUniqueName() + this.nameDelimiter;
        return prefix;
    },

    /**
    Apply a prefix to property names
    */
    getPrefixedData: function (data) {
        var name, newName, value,
            result = {};

        if (!data) {
            return null;
        }

        for (name in data) {
            value = data[name];
            newName = this.getPrefixedPath(name);
            result[newName] = value;
        }

        return result;
    },

    /**
    Get a descriptor with a prefix
    */
    getPrefixedDescriptor: function (descriptor) {
        var descriptorParts = this.expressionRe.exec(descriptor);

        if (!descriptorParts) {
            return descriptor;
        }

        var path = descriptorParts[2]; // '{foo}' -> 'foo'
        descriptor = descriptor.replace(path, this.getPrefixedPath(path));

        return descriptor;
    },

    /**
    Get a path with a correct prefix

    Examples:

        foo.bar -> viewmodel-123|foo.bar
        viewmodel|foo.bar -> viewmodel-123|foo.bar
        viewmodel-123|foo.bar -> viewmodel-123|foo.bar (no change)

    */
    getPrefixedPath: function (path) {
        var nameDelimiterPos = path.lastIndexOf(this.nameDelimiter),
            hasName = nameDelimiterPos != -1,
            name,
            isUnique,
            vmUniqueName,
            vm;

        if (hasName) {
            // bind to a ViewModel by name: viewmodel|foo.bar
            name = path.substring(0, nameDelimiterPos + this.nameDelimiter.length - 1);
            isUnique = this.uniqueNameRe.test(name);

            if (!isUnique) {
                // replace name by uniqueName: viewmodel-123|foo.bar
                vm = this.findViewModelByName(name);
                if (vm) {
                    vmUniqueName = vm.getUniqueName();
                    path = vmUniqueName + path.substring(nameDelimiterPos);
                }
                else {
                    Ext.log({ level: 'warn' }, 'Cannot find a ViewModel instance by a specifed name/type: ' + name);
                }
            }
        }
        else {
            // bind to this ViewModel: foo.bar -> viewmodel-123|foo.bar
            path = this.getPrefix() + path;
        }

        return path;
    },

    /**
    Find a ViewModel by name up by hierarchy
    @param {String} name ViewModel's name
    @param {Boolean} skipThis Pass true to ignore this instance
    */
    findViewModelByName: function (name, skipThis) {
        var result,
            vm = skipThis ? this.getParent() : this;

        while (vm) {
            if (vm.getName() == name) {
                return vm;
            }
            vm = vm.getParent();
        }

        return null;
    }
});

/**
This override replaces tokenRe to match a token with nameDelimiter
*/
Ext.define('Ext.ux.app.bind.Template', {
    override: 'Ext.app.bind.Template',
    
    tokenRe: /\{[!]?(?:(?:(\d+)|([a-z_][\w\-\.|]*))(?::([a-z_\.]+)(?:\(([^\)]*?)?\))?)?)\}/gi
});



Теперь так и пишем (только вместо точки другой символ, т.к. она зарезервирована):

Ext.define('Fiddle.view.OuterContainer', {
    viewModel: {
        name: 'outercontainer',
        data: {
            color: null
        }
    },
    
    items: [{
        xtype: 'innercontainer',
        bind: {
            color: '{outercontainer|color}'
        }
    }]
})

Демо на Sencha Fiddle.



Т.е. мы прописали более конкретный bind с указанием имени ViewModel'и. При вынесении кода ViewModel'и в отдельный файл, имя можно не указывать — оно возьмётся из alias. Всё, больше никаких изменений не требуется. На свою ViewModel можно привязываться по старинке без префикса. Его мы указываем для вложенных компонентов, у которых есть (или может появиться) своя ViewModel.

Под капотом этого расширения к полям ViewModel'и добавляется префикс, состоящий из её имени (name или alias) и уникального id (как для компонентов). Затем, в момент инициализации компонентов, он добавляется к названиям всех привязок.

Что это даёт?


Данные ViewModel'ей будут разделены по иерархии. В привязках будет конкретно видно, на свойство чьей ViewModel'и они ссылаются. Теперь можно не беспокоиться за дублирование свойств внутри иерархии ViewModel'ей. Можно писать повторно используемые компоненты без оглядки на родительский контейнер. В связке с предыдущими фиксами в сложных компонентах объём кода сокращается радикально.

Последний пример с фиксами №№1-3

Но на этом этапе частично теряется обратная совместимость. Т.е. если вы, разрабатывая компоненты, полагались на присутствие каких-то свойств во ViewModel'и родительского компонента, то последний фикс вам всё сломает: необходимо будет добавить в привязку префикс, соответствующий имени/alias'у родительской ViewModel'и.

Итого


Исходный код расширений лежит на GitHub, добро пожаловать:
github.com/alexeysolonets/extjs-mvvm-extensions

Они применены у нас в нескольких проектах — полёт более чем нормальный. Помимо того, что кода пишем меньше, появилось более чёткое понимание, как связаны компоненты — всё стало кристально ясно, голова уже не болит и перхоть исчезла.

Для себя есть один вопрос: оставить последнее расширение в виде глобального, которое действует на все ViewModel'и (override), или вынести как класс, от которого наследоваться? Второе решение вроде более демократично, но не внесёт ли оно большей путаницы? В общем, пока этот вопрос открытый.

Какие у вас были нюансы при разработке c MVVM? Обсудим?
Tags:extjsmvvm
Hubs: JavaScript ExtJS/Sencha
+7
17.7k 48
Comments 4
Top of the last 24 hours