Визуальное редактирование данных на странице, используя HTML как хранилище данных

в 14:44, , рубрики: html, html5, java, javascript, jQuery UI, web-services, Блог компании «ETNA Software», метки: , , , ,

Когда нам нужно предоставить пользователю возможность графического редактирования содержимого на странице, пожалуй, чаще всего мы используем JavaScript для хранения данных и передачи их на сервер, и все споры ведутся вокруг способа отображения, внешнего вида редактора. Наш выбор простирается от простого HTML (с холстом или без) до встроенного SVG или использования Flash плеера.

Выбрать между этими вариантами не сложно: SVG подойдёт для схем или планов и другой векторной графики, холст больше подходит для фотографий или других изображений. Однако, оба этих элемента требуют «отделения» себя от страницы. Под «отделением» я имею ввиду то, что любой из этих элементов требует написания дополнительных сценариев для синхронизации вида с моделью.

Для небольших объектов, структура которых хорошо описывается деревом или списком (например, корзина покупателя или бизнес-процесс), использование HTML элементов для отображения и хранения данных могло бы упростить разработку и поддержку.

Все данные уже на странице

HTML страница — это, прежде всего, документ, а не только разметка графических элементов, значит страница сама по себе может содержать все данные. Если учесть существование 'data' атрибутов, то можно сохранить почти всё что, что может быть представлено строкой. При этом сервер сможет сразу отдавать фрагмент страницы для отображения и его можно вставить «как есть» без дополнительной обработки. Так же есть преимущества и при отправке данных на сервер: можно отправить фрагмент «как есть» и пропустить через XSLT для получения XML документа, или отправить как “родную” веб-форму, или использовать JSON.

Представим, что у нас есть готовая страница; на ней представлены полка с товарами и корзинка покупателя реализованные простым списком.

<ul id="store-shelf">
    <li data-article="apple">Apple</li>
    <li data-article="milk">Milk</li>
    <li data-article="cacke">Cacke</li>
    <li data-topping="cherry" data-topping-article="cacke">Cherry Topping</li>
    <li data-topping="cream" data-topping-article="cacke">Cream Topping</li>
</ul>

<ul id="shop-cart"></ul>

<input name="reset" type="button" value="Reset"/>
<input name="submit" type="button" value="Submit"/>

Можно заметить, что часть товаров на полках — это яблоки, молоко и торты, но также на полках лежат дополнительные товары (опции), которые можно применять только совместно с другими товарами и не ко всем из них (у нас есть возможность выбрать торт со сливками или вишней, или с тем и другим). При этом вся необходимая информация о доступных товарах и их взаимной связи уже есть на странице и её не нужно отдельно хранить в JavaScript.

Используем сценарий для управления элементами

Добавим немного jQuery UI и начнём перетаскивать товары с полки в корзинку.

$("#shop-cart").droppable({
    accept: 'li[data-article]',
    activeClass: 'active',
    hoverClass: 'hover',
    drop: shopCartDropHandler
});
$('#store-shelf li').draggable({
    cursor: 'pointer',
    revert: true,
    stack: 'li'
});
function shopCartDropHandler(event, ui) {
    var clone, counter, $this = $(this),
        article = ui.draggable.attr('data-article'),
        existingGoods = $this.children('[data-article="' +
                               article + '"]:not(:has([data-topping]))');
    
    if (existingGoods.length) {
        counter = existingGoods.find('input[name="quantity"]');
        counter.val(parseInt(counter.val()) + 1);
    } else {
        counter = $('<input name="quantity" type="number" value="1" min="1"/>');
        clone = ui.draggable.clone().css({left: 0, top: 0}).append(counter);
        clone.droppable({
            accept: 'li[data-topping-article="' + article + '"]',
            activeClass: 'active',
            hoverClass: 'hover',
            drop: shopCartItemDropHandler
        });
        $this.append(clone);
    }
}
function shopCartItemDropHandler(event, ui) {
    var span, $this = $(this),
        topping = ui.draggable.attr('data-topping'),
        toppingName = ui.draggable.text(),
        existingToppings = $this.find('span[data-topping="' + topping + '"]');
    
    if (!existingToppings.length) {
        span = $('<span/>').attr('data-topping', topping).text(toppingName);
        $this.append(span);
    }
}

Здесь мы добавили своеобразную “группировку” (если элемент уже был добавлен, то просто увеличивается счётчик) и ввели поддержку двух типов элементов: товар и опция. Теперь соберём себе корзину с покупками. Если взять текущий фрагмент страницы с корзиной, то можно увидеть, что он полностью описывает её содержимое; все данные находятся на страницы без необходимости в дополнительных хранилищах на клиентской стороне (синхронизация с сервером — это иной случай и здесь мы его не рассматриваем). Ниже приведён пример готовой корзины (стили и атрибуты относящиеся к jQueryUI опущены).

<ul id="shop-cart" class="ui-droppable">
    <li data-article="cacke">Cacke
        <input name="quantity" type="number" value="2" min="1">
        <span data-topping="cherry">Cherry Topping</span>
        <span data-topping="cream">Cream Topping</span>
    </li>
    <li data-article="cacke">Cacke
        <input name="quantity" type="number" value="1" min="1">
        <span data-topping="cherry">Cherry Topping</span>
    </li>
    <li data-article="milk">Milk
       <input name="quantity" type="number" value="2" min="1">
    </li>
    <li data-article="apple">Apple
        <input name="quantity" type="number" value="1" min="1">
    </li>
</ul>

Видно, что у нас заказ на три торта (два со сливками и вишней и один только с вишней), две порции молока и одно яблоко.
Остаётся вопрос: как отправить эти данные на сервер и как отобразить их обратно. Для отправки можно использовать форму (часть атрибутов 'data' надо будет заменить на скрытые поля ввода), либо отправить Ajax запрос с JSON представлением элементов. Выбор того или иного варианта зависит от сложности готовой структуры: веб-форма плохо подойдёт для многоуровневых структур. Так как у нас могут быть два уровня в каждой позиции (основная с количеством и набор опций), используем JSON.

Просто сформируем JSON, который отражает существующую DOM структуру, извлекая данные из атрибутов и свойств элементов. При этом мы игнорируем “ярлыки” и названия товаров и опций, так как это информация не существенна и может быть легко и достоверно восстановлена на сервере.

$('input[name="submit"]').click(function () {
    var result = {items: []};

    shopCart.find('li[data-article]').each(function () {
        var $this = $(this),
            toppings = [],
            article = { id: $this.attr('data-article') },
            quantity = $this.find('input[name="quantity"]').val();
        
        $this.find('span[data-topping]').each(function () {
            toppings.push({ id: $(this).attr('data-topping') });
        });
        result.items.push({
            article: article,
            toppings: toppings,
            quantity: parseInt(quantity)
        });
    });

    $.ajax('/shopping-cart/webresources/cart', {
        cache: false,
        contentType: 'application/json',
        data: JSON.stringify(result),
        dataType: 'json',
        type: 'POST'
    });
});

Обработка данных на сервере

Обработка на сервере предполагается довольно простая: сохранение и выдача по запросу. Для этого воспользуемся функциональностью предоставляемой Glassfish и Jersey для создания веб-служб, подключения зависимостей и сериализации JSON. Создадим REST сервис, который будет сохранять корзину при POST запросе и выдавать сохранённый список покупок при GET запросе. Добавим еще один один объект, который будет хранить доступные товары и опции на полке и хранить текущую корзину. В реальном приложении эти задачи возьмёт на себя база данных или иной механизм сохранения.

@Path("cart")
@Singleton
@ApplicationScoped
public class ShoppingResource {

    @Inject private Storage storage;
    @GET @Produces(MediaType.APPLICATION_JSON)
    public ShoppingCart get() { eturn storage.getShoppingCart(); }
    
    @POST @Consumes(MediaType.APPLICATION_JSON)
    public void post(ShoppingCart shoppingCart) { storage.setShoppingCart(shoppingCart);}
}

@Named @Singleton @ApplicationScoped
public class Storage {
    private final Map<String, Article> articles = new HashMap<>();
    private final Map<String, Topping> toppings = new HashMap<>();
    private ShoppingCart shoppingCart;
    
    @PostConstruct public void init() {
        Article cacke = new Article("cacke", "Cacke");
        putArticles(cacke, new Article("milk", "Milk"), new Article("apple", "Apple"));
        putToppings(
            new Topping("cream", "Cream Topping", cacke),
            new Topping("cherry", "Cherry Topping", cacke));
    }
    private void putArticles(Article... articles) {
        for (Article article : articles) { his.articles.put(article.getId(), article); }
    }
    private void putToppings(Topping... toppings) {
        for (Topping topping : toppings) { this.toppings.put(topping.getId(), topping); }
    }

    @PreDestroy public void clear() { articles.clear(); toppings.clear(); }

    public Collection<Article> getArticles() { return articles.values(); }
    public Collection<Topping> getToppings() { return toppings.values(); }
    public ShoppingCart getShoppingCart() { return shoppingCart;    }
    
    public void setShoppingCart(ShoppingCart shoppingCart) {
        ensureConsistence(shoppingCart);
        this.shoppingCart = shoppingCart;
    }
    private void ensureConsistence(ShoppingCart cart) { /*
        Заменяет “неполные” элементы в корзине на “полные” из базы
     */ }
}

Нам так же потребуются классы для чтения и записи сериализованного представления корзины. Ниже приведён общий абстрактный класс, реализующий основную функциональность по чтению/записи товаров, опций и элементов корзины из JSON и обратно. Для каждого конкретного типа будет использован свой собственный класс основанный на этом поставщике.

@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public abstract class JsonProvider<T>
    implements MessageBodyReader<T>, MessageBodyWriter<T> {

    private static final Logger LOGGER = Logger.getLogger(JsonProvider.class.getName());
    private final ConcurrentMap<T, byte[]> serializedMap = new ConcurrentHashMap<>();
    private final ObjectMapper mapper = new ObjectMapper();

    protected final ObjectMapper getMapper() { return mapper; }
    protected abstract Class<T> getType();

    // Reader Implementation

    @Override
    public final boolean isReadable(Class<?> type, Type type1, Annotation[] antns, MediaType mt) {         
        return mt.equals(MediaType.APPLICATION_JSON_TYPE)
            && getMapper().canDeserialize(getJavaType())
            && getType().isAssignableFrom(type);
    }
    private JavaType getJavaType() {
        return TypeFactory.fromClass(getType());
    }

    @Override
    public T readFrom(Class<T> type, Type type1, Annotation[] antns,  MediaType mt, MultivaluedMap<String, String> mm, InputStream in)
            throws IOException, WebApplicationException {

        return (T) getMapper().readValue(in, getClass());
    }

    // Writer Implementation

    @Override
    public final boolean isWriteable(Class<?> type, Type type1, Annotation[] antns, MediaType mt) {
        return mt.equals(MediaType.APPLICATION_JSON_TYPE)
            && getMapper().canSerialize(getType())
            && getType().isAssignableFrom(type);
    }
    @Override
    public final long getSize(T t, Class<?> type, Type type1, Annotation[] antns, MediaType mt) {
        byte[] result = new byte[0];
        try {
            ByteArrayOutputStream stream = new ByteArrayOutputStream();
            getMapper().writeValue(stream, t);
            result = stream.toByteArray();
            serializedMap.put(t, result);
        } catch (IOException ex) {
            LOGGER.log(Level.SEVERE, null, ex);
        }
        return result.length;
    }
    @Override
    public final void writeTo(T t, Class<?> type, Type type1, Annotation[] antns, MediaType mt, MultivaluedMap<String, Object> mm, OutputStream out)
        throws IOException, WebApplicationException {

        if (serializedMap.containsKey(t)) {
            byte[] data = serializedMap.remove(t);
            out.write(data);
        }
        out.flush();
    }
}

Теперь для сохранения корзины мы будем использовать JSON, извлекая данные непосредственно из DOM, а при загрузке страницы, мы просто сформируем на сервере необходимые HTML фрагменты из сохранённых данных.

<ul id="store-shelf">
    <c:forEach var="article" items="#{storage.articles}">
        <li data-article="${article.id}">${article.name}</li>
    </c:forEach>
    <c:forEach var="topping" items="#{storage.toppings}">
        <li data-topping="${topping.id}"                
            data-topping-article="${topping.article.id}">${topping.name}</li>
    </c:forEach>
</ul>

<ul id="shop-cart">
    <c:forEach var="item" items="#{storage.shoppingCart.items}">
        <li data-article="${item.article.id}">${item.article.name}
            <c:forEach var="topping" items="${item.toppings}">
                <span data-topping="${topping.id}">${topping.name}</span>
            </c:forEach>
            <input name="quantity" type="number" value="${item.quantity}" min="1"/>
        </li>
    </c:forEach>
</ul>

В результате мы получили простую корзину покупок, которая в определённой степени реализует MVVM подход и не требует дополнительной синхронизации на клиентской стороне. В сочетании с REST службой, предоставляющей различные форматы в зависимости от запроса клиента, мы можем использовать несколько способов отображения данных (например отдавать XML данные сторонним службам).

Конечно, такое решение подойдёт для несложных случаев, когда модель хорошо представляется деревом; естественно для сложных случаев (с большим количеством элементов, либо когда модель плохо укладывается в “древесную” структуру HTML) потребуются другие решения (например SVG или холст расширенный с помощью сторонних библиотек) или что-то совершенно иное как Flash или JavaFX.

Автор: sviklim

Источник

Поделиться

* - обязательные к заполнению поля