Como funciona o decidim-forms

Como funciona o decidim-forms

A biblioteca de questionários que sustenta surveys, inscrições em reuniões e mais — sem ter uma única rota própria

1. Contexto: por que existe um módulo de formulários

Se você já leu a explicação do decidim-surveys, clique para expandir — ou pule para a seção 2.

O Decidim é uma plataforma Rails de participação cidadã organizada em gems independentes (decidim-core, decidim-surveys, decidim-meetings…). Suas convenções aparecem em todo o código que veremos: lógica de negócio em Commands que transmitem :ok/:invalid, validação de entrada em Form objects separados dos models, e views reutilizáveis em Cells.

Em 2017, o módulo de surveys ganhou um irmão: o decidim-forms, extraindo tudo o que um formulário dinâmico precisa — perguntas de vários tipos, opções, matrizes, condições — para que outros módulos pudessem montar questionários sem reescrever nada. Desde então, um survey é apenas um questionário com regras de janela de datas e publicação; uma inscrição em reunião é um questionário cujo envio chama JoinMeeting em vez de gravar respostas soltas.

Na explicação do decidim-surveys, vimos o forms pelo lado de fora: a associação polimórfica questionnaire_for, os models de perguntas e respostas, o command que grava. Agora vamos abrir o módulo por dentro — e a primeira surpresa é o que ele não tem.

2. A intuição central: uma biblioteca, não uma aplicação

Abra decidim-forms/lib/decidim/forms/engine.rb e procure as rotas. Não há nenhuma. O engine só registra view paths de cells, assets e um detalhe de ciclo de vida de dados (a transferência de respostas quando contas de usuário são fundidas, linhas 25–31). O admin engine idem. Um módulo Rails sem rotas parece inacabado — mas é exatamente o ponto: o forms é uma biblioteca de concerns, commands, form objects e views que os módulos consumidores montam dentro das suas próprias rotas.

O contrato de uso é um conjunto de "encaixes" com nomes estáveis:

  • No model, o consumidor inclui Decidim::Forms::HasQuestionnaire e ganha has_one :questionnaire, as: :questionnaire_for. Quem usa: Survey, Meeting.
  • No controller público, inclui Decidim::Forms::Concerns::HasQuestionnaire e herda show e respond prontos, respondendo a ganchos como questionnaire_for, allow_unregistered?, after_response_path. Quem usa: surveys (que só ajusta ganchos), meetings (que sobrescreve respond para chamar JoinMeeting/JoinWaitlistdecidim-meetings/app/controllers/decidim/meetings/registrations_controller.rb:9-33), demographics.
  • No admin, inclui Admin::Concerns::HasQuestionnaire (editor de perguntas) e/ou Admin::Concerns::HasQuestionnaireResponses (listagem e exportação de respostas). Quem usa: surveys, meetings, demographics e decidim-templates — que usa questionários como modelos reutilizáveis de formulário.
decidim-forms concerns · commands · forms · cells JS público e de admin · exporters decidim-surveys grava respostas decidim-meetings respond → JoinMeeting decidim-demographics dados sociodemográficos decidim-templates questionários-modelo
Figura 1 — O forms não serve páginas; ele é servido dentro das páginas dos outros.

Essa escolha tem um preço que o design paga de bom grado: nada no forms pode assumir URLs, nomes de parâmetros de rota ou contexto de navegação. Tudo que depende do hospedeiro é um método-gancho com implementação default. É um padrão de template method por convenção — e é o que permite que meetings desvie o envio para uma inscrição sem tocar em uma linha do forms.

3. O modelo de dados: questionário, perguntas, respostas

Sete tabelas, todas com prefixo decidim_forms_, contam a história inteira:

TabelaPapelColunas que importam
questionnaireso formuláriotitle/description/tos (jsonb traduzido), questionnaire_for_type/id, salt, published_at
questionsuma perguntaposition, question_type, mandatory, max_choices, max_characters, counter caches
response_optionsopção de escolhabody (jsonb), free_text (abre campo de texto junto à opção)
question_matrix_rowslinha de matrizbody, position
display_conditionsregra de exibiçãocondition_question_id, condition_type (enum), condition_value (jsonb), mandatory
responsesresposta a uma perguntabody, decidim_user_id (nullable), session_token, ip_hash
response_choicesopção escolhidadecidim_response_option_id, decidim_question_matrix_row_id, custom_body, position

Três observações sobre esse desenho:

Tudo que é texto visível ao usuário é jsonb traduzido ({"pt-BR": "…", "en": "…"}). O questionário é multilíngue por construção, não por adaptação.

O question_type é uma string entre dez valores (decidim-forms/app/models/decidim/forms/question.rb:9-12): oito tipos de resposta — short_response, long_response, single_option, multiple_option, sorting, files, matrix_single, matrix_multiple — e dois estruturais, separator e title_and_description, que não geram resposta alguma. Os estruturais parecem um hack; na verdade são o mecanismo de layout do formulário, como veremos na seção 6.

A resposta não é uma linha por formulário, é uma linha por pergunta. Um questionário de 12 perguntas gera 12 Responses por participante, ligadas pelo mesmo session_token (ou user). Isso torna "quem respondeu" uma queryDISTINCT session_token — e não uma coluna, e é o que permite publicar respostas pergunta a pergunta lá no surveys.

Vocabulário Até 2025, tudo se chamava answer: Answer, AnswerChoice, short_answer… A migration 20250314150250_rename_answer_to_response_in_decidim_forms.rb renomeou tabelas, colunas e tipos para response, e uma data migration posterior corrigiu as referências polimórficas em anexos, logs e versions. Documentação antiga diz "answer"; o código diz "response".

4. A intuição das condições de exibição: avaliadas duas vezes

O recurso mais sutil do módulo é a display condition: "mostre a pergunta 4 apenas se a pergunta 2 foi respondida com 'Sim'". Cinco operadores (DisplayCondition, linha 13):

  • responded — a pergunta-condição recebeu qualquer resposta;
  • not_responded — não recebeu;
  • equal / not_equal — a escolha foi (ou não) uma opção específica;
  • match — o texto da resposta casa com uma expressão regular.

A parte delicada não é a avaliação — é onde ela acontece. Uma condição só é útil se esconder a pergunta enquanto o usuário digita (senão, para que condição?), mas só é confiável se for verificada no servidor no momento de gravar (senão, qualquer POST forjado responde perguntas escondidas). O forms faz as duas coisas, com a mesma semântica implementada duas vezes:

Navegador (UX) display_conditions.component.js mostra/esconde e DESABILITA inputs Servidor (verdade) ResponseForm#display_conditions_fulfilled? decide se a resposta é gravada e se é obrigatória DisplayCondition#to_html_data {id, type, condition, option, mandatory, value}
Figura 2 — O model exporta a condição como data-*; o JS e o form object interpretam a mesma regra em dois mundos.

E há uma segunda camada de sutileza: obrigatoriedade é relativa à visibilidade. Uma pergunta mandatory cuja condição não se cumpriu não é obrigatória — os predicados mandatory_body?/mandatory_choices? (response_form.rb:98-104) só exigem resposta se display_conditions_fulfilled?. Sem esse gate, um formulário ramificado seria impossível de submeter: o servidor exigiria respostas para perguntas que o usuário nunca viu.

Regra de composição Quando uma pergunta tem várias condições: se alguma for mandatory: true, todas as obrigatórias precisam se cumprir (AND); se só houver opcionais, qualquer uma basta (OR). Está em response_form.rb:68-82, e o JS (mustShow(), display_conditions.component.js:156-167) espelha exatamente essa lógica.

5. Passos, separadores e a ilusão do wizard

Um questionário longo vira um wizard de vários passos — mas não existe "passo" no banco. O mecanismo é uma partição derivada de um único lugar: responses_by_step (questionnaire_form.rb:46-53) fatia as perguntas a cada separator:

def responses_by_step
  @responses_by_step ||= begin
    steps = responses.slice_before { |r| r.question.separator? }.to_a
    steps.each { |step| step.reject! { |r| r.question.separator? } }
    steps.reject(&:blank?)
  end
end

O servidor renderiza todos os passos no HTML, escondendo os posteriores; os botões "voltar/continuar" do StepNavigationCell apenas alternam a visibilidade das div#step-N via Stimulus. Não há requisição entre passos, não há estado parcial no servidor — o formulário é um só, e o wizard é uma ilusão de apresentação. Isso simplifica tudo: validação é do formulário inteiro, condições de exibição funcionam através dos passos, e um refresh não perde nada (os dados estão no DOM).

6. O código: form objects, a fronteira de validação

Seguindo a convenção do Decidim, nenhuma validação de entrada mora nos models. A fronteira é o ResponseForm (decidim-forms/app/forms/decidim/forms/response_form.rb), e a tabela de regras que ele aplica é um bom mapa do que uma resposta pode ser:

RegraOndeDetalhe
corpo obrigatóriolinhas 17, 98–104só se a pergunta está visível (condições cumpridas)
escolha obrigatórialinhas 18, 98–104idem
max_choiceslinhas 20, 110–116em matriz, o limite vale por linha, não pelo total
sorting completolinhas 21, 132–134é preciso ordenar todas as opções
matriz obrigatórialinhas 22, 128–130uma escolha por linha da matriz
arquivo obrigatóriolinhas 23, 144–146para o tipo files
max_characterslinhas 24, 118–126aplica ao corpo e ao custom_body de opções free-text

Acima dele, o QuestionnaireForm segura as regras do formulário inteiro: aceite dos termos (tos_agreement, linha 19), presença do session_token no contexto (linhas 59–63 — sem token, não há como deduplicar, então o POST é rejeitado como request_invalid), e um detalhe de arquitetura bonito no before_validation (linhas 38–40): todas as respostas do formulário são injetadas no context para que cada ResponseForm possa avaliar condições cruzadas — a validade da pergunta 4 depende do que foi preenchido na pergunta 2, no mesmo request.

E abaixo dele, o command ResponseQuestionnaire persiste em transação apenas as respostas com display_conditions_fulfilled? — a segunda avaliação da seção 4 — com tratamento de anexos e rollback em caso de erro. O resultado: o model Response quase não valida nada (apenas coerência de organização e pertencimento da pergunta), porque a fronteira já filtrou tudo.

7. O código: o editor admin de perguntas

O editor de questionários do admin é a peça mais engenhosa do módulo, porque resolve no cliente um problema que o Rails tradicionalmente resolve mal: editar uma árvore inteira — perguntas, opções, linhas de matriz, condições — num único formulário, com drag-and-drop.

A engrenagem tem três peças que se encaixam por placeholders de ID:

  • Templates embutidos. A view _questions_form.html.erb (linhas 13–37) renderiza blocos HTML dentro de <script type="text/template">, com marcadores como questionnaire-question-id no lugar dos IDs reais.
  • O form object coopera. QuestionForm#to_param retorna exatamente o placeholder quando o registro é novo (linhas 31–35) — então o HTML de uma pergunta nova já sai com o marcador certo.
  • O JS substitui na cópia. createEditableForm() (app/packs/src/decidim/forms/admin/forms.js) usa createDynamicFields: ao clicar "adicionar pergunta", o template é clonado e cada placeholder vira um ID único. Remoção é soft no cliente: o item ganha deleted=true num hidden e some da tela — só o submit decide seu destino.

No servidor, Admin::UpdateQuestions (decidim-forms/app/commands/decidim/forms/admin/update_questions.rb) traduz essa árvore para o banco com um padrão genérico, update_nested_model (linhas 91–105):

record = question_set.find_by(id: form.id) || question_set.build

if record.persisted?
  form.deleted? ? record.destroy! : record.update!(attrs)
else
  record.save!
end

E há duas guardas de ordem em volta. A primeira é temporal: o command só persiste se questions_editable? — componente não publicado ou questionário ainda sem respostas (linha 28). A segunda é espacial, no DisplayConditionForm: condition_question_position (linhas 92–96) exige que a pergunta condicionada não seja a primeira — e o JS remove condições da primeira posição quando o admin arrasta uma pergunta condicionada para o topo. Afinal, uma condição só pode apontar para uma pergunta anterior: não faz sentido condicionar algo a uma resposta que ainda não foi dada.

Convenção silenciosa Repare na direção das dependências: o JS conhece os placeholders que os form objects declaram em to_param, e a view conhece os seletores que o JS procura. Nada disso é configurável — é convenção pura. Trocar o nome de uma classe CSS ou de um placeholder exige tocar os três lados, e os specs de sistema (manage_questionnaires, shared examples em lib/decidim/forms/test/shared_examples/) são a rede de segurança.

8. O código: o JavaScript público

Do lado do participante, o pack decidim_forms (app/packs/src/decidim/forms/forms.js) é deliberadamente pequeno e cumpre quatro papéis:

  • Condições em tempo realcreateDisplayConditions lê os data-* emitidos por DisplayCondition#to_html_data e observa os inputs da pergunta-condição. Esconder uma pergunta também desabilita seus inputs (linhas 186–195 do componente), para que nem sequer viajem no POST — a defesa do servidor contra respostas fantasmas começa aqui, no cliente cooperativo.
  • Limites visíveisMaxChoicesAlertComponent avisa quando os checkboxes marcados passam de data-max-choices; opções free_text ganham um campo de texto que liga e desliga junto com a escolha (OptionAttachedInputsComponent).
  • Sorting por arraste — a ordenação de opções usa drag-and-drop (DragonDrop), inclusive com um workaround de scroll em telas de toque.
  • Proteção contra perdapreventUnload avisa se o usuário tenta sair com o formulário alterado.

A tese unificadora: o JS público nunca decide nada. Toda regra que ele aplica existe, reimplementada, no form object. O cliente acelera a experiência; o servidor é a verdade.

9. O código: ler as respostas — queries, serializers, exportações

Gravar foi metade da história; a outra metade é dar sentido ao que voltou. O ponto de partida é sempre a mesma pergunta: quem é um participante? A resposta do módulo está em QuestionnaireUserResponses (app/queries/decidim/forms/questionnaire_user_responses.rb:22-30):

responses.group_by { |response| response.user || response.session_token }

Um participante é o usuário logado — ou, para anônimos, o token pseudo-anônimo. Dessa agrupação derivam tudo o mais: a contagem de participantes (QuestionnaireParticipants, com DISTINCT ON (session_token)), a listagem admin, e as exportações.

O UserResponsesSerializer (lib/decidim/forms/user_responses_serializer.rb) transforma o conjunto de respostas de um participante numa linha de planilha, e dois detalhes revelam cuidado de design:

  • Colunas alinhadas: questions_hash pré-popula todas as perguntas com string vazia (linhas 46–58), de modo que quem pulou a pergunta 5 não desalinha o CSV de ninguém;
  • Normalização por tipo: normalize_body (linhas 64–68) converte cada resposta para sua forma legível — texto puro, URLs de anexos, lista de escolhas, ou um hash linha→colunas para matrizes; opção free-text vira "opção (texto livre)".

A exportação pesada roda em background: ExportQuestionnaireResponsesJob gera o arquivo (incluindo PDF formatado por participante, via Decidim::Exporters::FormPDF), anexa como export privado e avisa por e-mail com o mailer do core — o forms não tem mailer próprio, mais um sinal de que ele é uma peça dentro da plataforma, não uma aplicação.

E há um terceiro serializer, menos visível e talvez o mais importante: DownloadYourDataUserResponsesSerializer, registrado em Response.export_serializer (response.rb:31-33). É ele que coloca as respostas de um participante no pacote "baixe seus dados" — a obrigação de portabilidade (GDPR) não é um recurso à parte, é o mesmo pipeline de serialização com outro consumidor.

10. Síntese

O decidim-forms é construído sobre quatro decisões que se reforçam:

  • Biblioteca, não aplicação — sem rotas próprias; concerns com ganchos que surveys, meetings e demographics montam em seus contextos, cada um desviando o que precisa (meetings troca até o significado de "enviar");
  • Resposta por pergunta, participante por agrupação — a identidade do participante é uma query sobre session_token/user, o que torna anonimato, deduplicação e exportação faces do mesmo mecanismo;
  • Toda regra vive duas vezes — condições de exibição, limites de escolha e obrigatoriedade têm uma versão de cliente (rápida, cooperativa) e uma de servidor (lenta, autoritativa), geradas da mesma fonte: o model;
  • A árvore se edita inteira — o editor admin resolve perguntas aninhadas com placeholders de ID e soft-delete no cliente, e um command que reconcilia a árvore recebida com a persistida, sob a guarda de que questionários com respostas congelam suas perguntas.

Se o decidim-surveys é um estudo de "dizer pouco e reutilizar muito", o forms é o que torna isso possível: a fundação que escolheu não ter páginas próprias para poder estar em todas.