четверг, 17 июля 2014 г.

Confirmation dialog в RichFaces 4

Не так давно я решил лично узнать, за что все кому не лень ругают JSF. Касаемо web-разработки, на тот момент у меня был лишь некоторый опыт работы с фреймворком ZK (его создатели, кстати тоже хают JSF и некоторых другие фреймворки, но в данном случае делают они это скорее в маркетинговых целях).

Собравшись изучать JSF (разумеется версию 2.x), я выбрал достаточно суровую, на мой взгляд, библиотеку компонентов - RichFaces (последней стабильной версией на тот момент была 4.3.4.Final, в исходниках же к статье используется версия 4.3.7.Final - принципиальных различий нет) - и через недолгое время знакомства с ней столкнулся с элементарной, казалось бы, проблемой: по щелчку на кнопке/ссылке показать пользователю модальный диалог с каким-нибудь вопросом "Do you really want to do something?", а также парой кнопок OK и Cancel. В принципе, для такого простого случая взаимодействия с пользователем можно было бы обойтись и JavaScript-функцией confirm, но что если заказчику или вам самим захочется чего-то особенного. К примеру, чтобы окно диалога было выполнено в определённом стиле. В общем, стандартного компонента в RichFaces для этой цели нет, а в интернетах предлагается решение на основе JavaScript, отображающего скрытый модальный диалог. Но мне больше понравился подход PrimeFaces. В этой библиотеке есть готовый компонент confirmDialog. Взгляните на демку, чтобы ознакомиться с его работой. Принцип действия схож с решениями для RichFaces, однако выглядит всё более целостно и возможно такой подход поможет немного снизить степень гремучести смеси "html/el/javascript и еще черт знает чего" ©, из которой состоят представления в JSF. Ниже приведено краткое руководство по созданию в RichFaces аналога компонента confirmDialog (тут исходники).

Алгоритм работы диалогового окна подтверждения (далее просто - диалога) примерно таков:
  • с помощью особого поведения (behavior), добавленного к кнопке или ссылке, по щелчку на которой мы хотим показывать диалог, припрятать стандартный обработчик события onclick в data-атрибуте элемента кнопки/ссылки, а вместо него вставить вызов нашего диалога;
  • по щелчку на вышеупомянутой кнопке/ссылке найти на странице заблаговременно размещённый диалог и показать его пользователю;
  • по щелчку на кнопке OK в диалоге взять кнопку/ссылку, вызвавшую этот диалог, получить стандартный обработчик события onclick из того самого data-атрибута и выполнить этот обработчик, спрятав диалог до следующего вызова;
  • по щелчку на кнопке Cancel просто закрыть диалог.
Всё просто и начать стоит с подготовки компонентов-инициаторов вызова диалога. Перво-наперво нам понадобится особый интерфейс, говорящий о том, что реализующий его компонент способен вызвать диалог:
public interface Confirmable {
    public String getConfirmationScript();
    public void setConfirmationScript(String confirmationScript);
    public boolean requiresConfirmation();
}
Методами getConfirmationScript/setConfirmationScript можно получить/сохранить специфичный для текущего компонента JavaScript, вызывающий диалог.

А вот кнопка на основе компонента org.richfaces.component.UICommandButton, реализующая интерфейс Confimable (про ссылки писать не буду - там всё делается аналогично):
@FacesComponent("org.positivefaces.CommandButton")
public class UICommandButton extends org.richfaces.component.UICommandButton
        implements Confirmable {
    private ConfirmationScriptHolder confirmScriptHolder = new ConfirmationScriptHolder();

    @Override
    public String getConfirmationScript() {
        return confirmScriptHolder.getConfirmationScript();
    }

    @Override
    public void setConfirmationScript(String confirmationScript) {
        confirmScriptHolder.setConfirmationScript(confirmationScript);
    }

    @Override
    public boolean requiresConfirmation() {
        return confirmScriptHolder.requiresConfirmation();
    }
}
Класс ConfirmationScriptHolder призван чуточку сократить дублирование кода при реализации интерфейса Confirmable в каком-нибудь другом компоненте:
public class ConfirmationScriptHolder {
    private String confirmationScript;

    public String getConfirmationScript() {
        return confirmationScript;
    }

    public void setConfirmationScript(String confirmationScript) {
        this.confirmationScript = confirmationScript;
    }

    public boolean requiresConfirmation() {
        return !Strings.isNullOrEmpty(confirmationScript);
    }
}
Несколько сомнительное решение, но перфекционизм не даёт покоя.

Теперь нашей кнопке нужен рендерер:
@FacesRenderer(rendererType = "org.positivefaces.CommandButtonRenderer", componentFamily = UICommand.COMPONENT_FAMILY)
public class CommandButtonRenderer extends org.richfaces.renderkit.html.CommandButtonRenderer {
    @Override
    public void doEncodeEnd(ResponseWriter writer, FacesContext context, UIComponent component) throws IOException {
        super.doEncodeEnd(new ConfirmCommandResponseWriter(writer, (UICommandButton) component), context, component);
    }

    @Override
    protected Class<? extends UIComponent> getComponentClass() {
        return UICommandButton.class;
    }
}
За время знакомства с JSF у меня сложилось впечатление, что легко расширить нужный компонент или рендерер - большая удача (в ZK, к слову, с этим тоже не всё гладко). Пару раз я встречал обходные решения на основе обёртки над ResponseWriter'ом. Этот случай не исключение:
public class ConfirmCommandResponseWriter extends ResponseWriterWrapper {
    private ResponseWriter originalWriter;
    private Confirmable confirmableCommand;

    public ConfirmCommandResponseWriter(ResponseWriter originalWriter, Confirmable confirmableCommand) {
        this.originalWriter = originalWriter;
        this.confirmableCommand = confirmableCommand;
    }

    @Override
    public ResponseWriter getWrapped() {
        return originalWriter;
    }

    @Override
    public void writeAttribute(String name, Object value, String property) throws IOException {
        if (HtmlConstants.ONCLICK_ATTRIBUTE.equals(name) && confirmableCommand.requiresConfirmation()) {
            super.writeAttribute("data-pfconfirmcommand", value, null);
            value = confirmableCommand.getConfirmationScript();
        }
        super.writeAttribute(name, value, property);
    }
}
С помощью этой обёртки мы перехватываем запись атрибута onclick для нашей кнопки и заменяем стандартный обработчик на вызов диалога (пункт 1 в алгоритме работы всего этого добра). Не очень красиво, но от копипаста методов на 100+ строк кода мне становится нехорошо.

В принципе, компонент готов. Остаётся только зарегистрировать его в библиотеке тегов и мы переходим к реализации поведения:
@FacesBehavior("org.positivefaces.behavior.ConfirmBehavior")
@ResourceDependency(library = "org.positivefaces", name = "js/positivefaces.js")
public class ConfirmBehavior extends ClientBehavior {
    @Override
    public String getScript(ClientBehaviorContext behaviorContext) {
        FacesContext context = behaviorContext.getFacesContext();
        UIComponent component = behaviorContext.getComponent();
        String source = component.getClientId(context);
        if (component instanceof Confirmable) {
            String script = "PositiveFaces.confirm({"
                    + "source:'" + source + "',"
                    + "sourceEvent:event,"
                    + "header:'" + getHeader() + "',"
                    + "message:'" + getMessage() + "',"
                    + "icon:'" + getIcon() + "'"
                    + "});return false;";
            ((Confirmable) component).setConfirmationScript(script);
            return null;
        }
        throw new FacesException("Component " + source + " is not "
                + "\"confirmable\". ConfirmBehavior can only be attached to "
                + "component that implements " + Confirmable.class.getName()
                + " interface");
    }

    ...
}
Код этого класса практически эквивалентен коду класса org.primefaces.behavior.confirm.ConfirmBehavior: в целевом компоненте, реализующем интерфейс Confirmable, сохраняется скрипт вызова JavaScript-функции confirm, в которую передаются заголовок диалога, сообщение пользователю и, возможно, ссылка на какую-нибудь картинку. Чудесная JavaScript-функция confirm определена в файле positivefaces.js (от которого, кстати, зависит наш ConfirmBehavior):
(function($, rf) {
    var pf = window.PositiveFaces = window.PositiveFaces || {};

    pf.confirm = function(msg) {
        if (pf.confirmDialog) {
            pf.confirmSource = $("#" + msg.source.replace(/:/g, "\\:"));
            pf.confirmSourceEvent = msg.sourceEvent;
            pf.confirmDialog.showMessage(msg);
        } else
            rf.log.warn('No global confirmation dialog available');
    };
})(jQuery, window.RichFaces);
Вроде бы всё просто - если на странице есть диалог, выставляем ему кой-какие параметры и являем на свет божий.

Последний ингредиент нашего блюда - сам диалог. Реализация класса компонента довольно скучна (мы просто наследуемся от org.richfaces.component.UIPopupPanel). Рендерер диалога немного интереснее. Его класс - ConfirmDialogRenderer - представляет собой довольно длинную партянку, бОльшая часть кода в которой направлена на предоставление возможности легко улучшить внешний вид диалога с помощью CSS (и действительно, наш диалог с навешанными на него CSS-классами Twitter Bootstrap выглядит, на мой взгляд, весьма недурно). Основная же проблема заключается в необходимости использовать на клиентской стороне JavaScript'овый класс диалога, наследующийся от  RichFaces.ui.PopupPanel. И вновь в этом нелёгком деле нам на помощь приходят привычные уже техники подмены ResponseWriter'ов. Здесь мною был опробован ещё более извращённый подход, базирующийся на классе org.ajax4jsf.io.SAXResponseWriter. С помощью его специального наследника, а также внутреннего по отношению к рендереру класса XMLConsumer мы регулярным выражением new\s+([\w.]+)\( отлавливаем тот момент, когда базовый класс записывает вызов конструктора клиентского класса и коварно подменяем его:
@Override
public void characters(char[] ch, int start, int length) throws SAXException {
    if (ch != null && ch.length > 0 && isJavaScript(startedElements.peek())) {
        String text;
        if (start == 0 && ch.length == length)
            text = new String(ch);
        else
            text = new String(Arrays.copyOfRange(ch, start, start + length));
        Matcher matcher = CLIENT_COMPONENT_CTOR_CALL.matcher(text);
        if (matcher.find()) {
            ch = matcher.replaceFirst("new PositiveFaces.ui.ConfirmDialog(").toCharArray();
            start = 0;
            length = ch.length;
        }
    }
    super.characters(ch, start, length);
}
Код клиентской части диалога скрыт в файле confirmdialog.js. И опять же, по большей части это обеспечение корректного отображения диалога. Самое главное - это обработчик кнопки OK:
this.btnPaneDiv.find('.' + STYLE_CLASS_OK).click(function(e) {
    if (pf.confirmSource) {
        var fn = eval('(function(event){'
            + pf.confirmSource.data('pfconfirmcommand')
            + '})');
        fn.call(pf.confirmSource, pf.confirmSourceEvent);
        pf.confirmDialog.hide();
        pf.confirmSource = null;
        pf.confirmSourceEvent = null;
    }
    e.preventDefault();
});
Да, да. eval - это нехорошо, тем не менее, в PrimeFaces сделано именно так. Код, на мой взгляд, кристально ясен: берем у кнопки/ссылки значение атрибута data-pfconfirmcommand и исполняем как JavaScript-функцию, после чего скрываем диалог до следующего раза.

На этом всё. Надеюсь, кому-нибудь что-нибудь из этой статьи да и окажется полезным.

Подводя краткий итог недолгому знакомству с JSF, не могу сказать, что это что-то ужасное. Да, иногда тяжеловесно, порой нелогично, а временами просто выносит мозг. Но технология развивается и определённо имеет право на жизнь. Библиотека же RichFaces оказалась действительно суровой. На её фоне та же PrimeFaces выглядит значительно привлекательнее.

Комментариев нет:

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