AngularJS - Forçando bind de campos $pristine no submit


Quando trabalhamos com aplicações AngularJS é comum e até trivial criarmos formulários e seus respectivos bindings para um objeto Javascript do $scope de um determinado controller. Ao submeter o formulário esse objeto será então enviado para o backend para o processamento da ação do formulário em questão, mas e o que acontece quando nem todos os campos do formulário são obrigatórios?

Funcionamento padrão

Por padrão, o tal do campo não-obrigatório só será incluído no binding quando o usuário interagir de alguma forma com ele. Para ilustrar considere o Controller abaixo.

myApp.controller('ContactController', ['$scope', '$http',
  function($scope, $http) {
    $scope.contact = {};
    $scope.submit = function() {
      console.log($scope.contact);
      $http.post('/contact').success(function(response) {
        console.log('Sent!');
      });
    };
  }
]);

Temos então um atributo chamado contact que será o objeto com os dados do contato do formulário apresentado a seguir.

<form name="form" ng-submit="submit()">
  <label for="name">Name:</label>
  <input type="text" name="name" id="name" ng-model="contact.name" ng-required="true" />
  <label for="obs">Obs:</label>
  <textarea name="obs" id="obs" ng-model="contact.obs"></textarea>
  <input type="submit" value="Enviar" />
</form>

O que acontece quando o nome é preenchido e o botão “Enviar” é pressionado, sem nem sequer interagir com o campo “Observação”? Se verificar o console, o nosso contact estará assim:

Object {name:"Nome"}

Por que que o campo “Observação” não está presente? Porque por padrão o AngularJS exige que os campos tenham alguma interação do usuário para efetuar o binding. Experimente preencher algo no campo “Observação” e confira o que aparecerá no console:

Object {name:"Nome", obs: "Algum conteúdo"}

Agora experimente preencher algo e logo depois apagar o que preencheu e confira o resultado:

Object {name:"Nome", obs: ""}

Os estados do Formulário

O AngularJS define alguns estados quando trabalhamos com formulários e campos, você pode conferir esses estados ao inspecionar o formulário via Chrome DevTools e checar as classes presentes:

  • $pristine: O formulário/campo ainda não teve nenhuma interação do usuário
  • $dirty: O formulário/campo já teve interação com o usuário
  • $valid: O formulário/campo está preenchido e válido
  • $invalid: O formulário/campo está preenchido indevidamente

Por que é importante saber sobre esses estados? Para entender como o AngularJS trata toda essa interação do usuário com o formulário e para entender a possível solução que encontrei para esse problema!

Resolvendo

Então já que o backend exige que o campo obs esteja sempre presente, mesmo quando vazio, precisaremos forçar que campos $pristine sejam incluídos no binding do nosso objeto. Considere que o código abaixo esteja incluído na função $scope.submit:

angular.forEach($scope.form, function(value, key) {
  if (value.hasOwnProperty('$modelValue')) {
    if (!value.$viewValue) {
      value.$setViewValue("");
    }
  }
});

Ao testar novamente o formulário, preenchendo somente o campo e enviando o formulário, o resultado no console será:

Object {name:"Nome", obs: ""}

O que esse código fez? Primeiro é definido um forEach para todos os campos do formulário.

angular.forEach($scope.form, function(value, key) {

Depois é verificado se o elemento atual tem binding com algum model.

if (value.hasOwnProperty('$modelValue')) {

Se o campo tem binding e não está preenchido ou não existe interação com ele, define que seu valor será "" e reflita isso no model.

value.$setViewValue("");

Pronto, agora campos que estão $pristine serão marcados como $dirty e o binding sempre incluirá todos os campos de seu model ! Só que esse código possui um pequeno problema: o binding vai ser executado até com o formulário inválido. Isso pode ser resolvido facilmente:

if (form.$valid) {
  // código para forçar o binding
}

Note que, com esta solução seria necessário que todos os controllers tenham o trecho de código apresentado, então seria interessante criar uma directive com esse código para que posteriormente você só defina isso como um atributo de seu formulário, algo como apresentado abaixo.

<form name="form" ng-submit="submit()" force-bind>

A directive pode ficar algo como o seguinte código.

myApp.directive('forceBind',  function() {
  return {
    require: '^form',
    priority: -1,
    link: function (scope, element, attrs, form) {
      element.bind('submit', function() {
        if (form.$valid) {
          angular.forEach(form, function(value, key) {  
            if (value.hasOwnProperty('$modelValue')) {
              if (!value.$viewValue) {
                value.$setViewValue("");
              }
            }
          });
        }
      });
    }
  };
});

Foge do escopo do artigo explicar mais detalhadamente directives, mas o que está sendo feito é:

  • Executar a directive com prioridade -1, ou seja, antes do ngSubmit, que tem prioridade 0
  • Quando o evento submit for executado, força o bind caso o formulário seja válido.

Referências

Veja o código funcionando em: http://jsfiddle.net/pgfLaomd/3/.

Até a próxima.