A biblioteca de questionários que sustenta surveys, inscrições em reuniões e mais — sem ter uma única rota própria
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.
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:
Decidim::Forms::HasQuestionnaire e ganha has_one :questionnaire, as: :questionnaire_for. Quem usa: Survey, Meeting.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/JoinWaitlist — decidim-meetings/app/controllers/decidim/meetings/registrations_controller.rb:9-33), demographics.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.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.
Sete tabelas, todas com prefixo decidim_forms_, contam a história inteira:
| Tabela | Papel | Colunas que importam |
|---|---|---|
questionnaires | o formulário | title/description/tos (jsonb traduzido), questionnaire_for_type/id, salt, published_at |
questions | uma pergunta | position, question_type, mandatory, max_choices, max_characters, counter caches |
response_options | opção de escolha | body (jsonb), free_text (abre campo de texto junto à opção) |
question_matrix_rows | linha de matriz | body, position |
display_conditions | regra de exibição | condition_question_id, condition_type (enum), condition_value (jsonb), mandatory |
responses | resposta a uma pergunta | body, decidim_user_id (nullable), session_token, ip_hash |
response_choices | opção escolhida | decidim_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 query — DISTINCT session_token — e não uma coluna, e é o que permite publicar respostas pergunta a pergunta lá no surveys.
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".
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:
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.
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.
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).
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:
| Regra | Onde | Detalhe |
|---|---|---|
| corpo obrigatório | linhas 17, 98–104 | só se a pergunta está visível (condições cumpridas) |
| escolha obrigatória | linhas 18, 98–104 | idem |
max_choices | linhas 20, 110–116 | em matriz, o limite vale por linha, não pelo total |
| sorting completo | linhas 21, 132–134 | é preciso ordenar todas as opções |
| matriz obrigatória | linhas 22, 128–130 | uma escolha por linha da matriz |
| arquivo obrigatório | linhas 23, 144–146 | para o tipo files |
max_characters | linhas 24, 118–126 | aplica 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.
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:
_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.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.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.
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.
Do lado do participante, o pack decidim_forms (app/packs/src/decidim/forms/forms.js) é deliberadamente pequeno e cumpre quatro papéis:
createDisplayConditions 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.MaxChoicesAlertComponent 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).preventUnload 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.
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:
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;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.
O decidim-forms é construído sobre quatro decisões que se reforçam:
session_token/user, o que torna anonimato, deduplicação e exportação faces do mesmo mecanismo;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.