Class: Quby::Compiler::Outputs::QubyFrontendV2Serializer

Inherits:
Object
  • Object
show all
Defined in:
lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb

Instance Method Summary collapse

Constructor Details

#initialize(questionnaire, translations: {}) ⇒ QubyFrontendV2Serializer

Returns a new instance of QubyFrontendV2Serializer.



7
8
9
10
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 7

def initialize(questionnaire, translations: {})
  @questionnaire = questionnaire
  @translations = translations
end

Instance Method Details

#as_json(options = {}) ⇒ Object



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 14

def as_json(options = {})
  {
    key: key,
    layoutVersion: layout_version || :v1,
    title: title,
    language: language || "nl",
    description: description,
    shortDescription: short_description,
    footer: footer,
    defaultAnswerValue: Services::TransformQuby1ValuesIntoQuby2Values.run!(@questionnaire, default_answer_value),
    panels: panels.map { panel(_1) },
    questions: questions,
    textvars: textvars,
    validations: validations,
    visibilityRules: visibility_rules.as_json,
    sexpVariables: sexp_variables,
    cssVars: css_vars,
    versionNumber: version_number,
    translations: translations,
  }.compact.tap do |json|
    validate_all_questions_in_a_panel_question_keys(json)
  end
end

#base_question(question) ⇒ Object



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 207

def base_question(question)
  {
    key: question.key,
    title: handle_html(question.title),
    description: handle_html(question.description, type: :question_description),
    contextDescription: handle_html(question.context_description, type: :prose, v1_markdown: false),
    type: question_type(question),
    cssVars: question.css_vars,
    indent: question.indent,

    hidden: question.hidden?,
    displayModes: question.display_modes,
    viewSelector: question.view_selector,
    parentKey: question.parent&.key,
    parentOptionKey: question.parent_option_key,
    deselectable: question.deselectable,
    presentation: question.presentation,
    as: question.as || question_type(question), # default to type so typescript can narrow on it.
    questionGroup: question.question_group,
  }
end

#check_box_question(question) ⇒ Object



118
119
120
121
122
123
124
125
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 118

def check_box_question(question)
  {
    **base_question(question),
    children: children(question),
    checkAllOption: question.check_all_option,
    uncheckAllOption: question.uncheck_all_option,
 }.compact
end

#children(question) ⇒ Object



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 258

def children(question)
  question.options.map.with_index { |child, idx|
    if child.is_a?(Quby::Compiler::Entities::QuestionOptgroup)
      { type: 'optgroup',
        key: child.key,
        label: child.label,
        options: child.options.map { option_as_json(_1) }
      }
    elsif child.inner_title
      inner_title_as_json(child, idx)
    elsif child.placeholder
      nil # placeholder attr on question.
    else
      option_as_json(child)
    end
  }.compact
end

#date_parts_question(question) ⇒ Object



127
128
129
130
131
132
133
134
135
136
137
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 127

def date_parts_question(question)
  {
    **base_question(question),
    dateParts: question.components.map { |component|
      {
        part: component,
        key: question.send("#{component}_key"),
      }
    },
  }.compact
end

#depends_on(validation_hsh) ⇒ Object



339
340
341
342
343
344
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 339

def depends_on(validation_hsh)
  question = @questionnaire.question_hash[validation_hsh['fieldKey']]
  return unless question.depends_on.present?

  question.depends_on.select { @questionnaire.question_hash.key?(_1) }
end

#depends_on_options(validation_hsh) ⇒ Object



346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 346

def depends_on_options(validation_hsh)
  question = @questionnaire.question_hash[validation_hsh['fieldKey']]
  return unless question.depends_on.present?
    
  question.depends_on
    .map(&:to_s)
    .reject { @questionnaire.question_hash.key?(_1) }
    .map { |key|
      question_key, option_key = key.split(/_(?=[^_]+$)/) # split on last _, works for all existing questionnaires.
      if @questionnaire.question_hash.key?(question_key)
        if @questionnaire.question_hash[question_key].options.any? { _1.key.to_s == option_key } # single_select
          next [question_key, option_key]
        elsif @questionnaire.question_hash[question_key].options.any? { _1.key.to_s == key } # multi_select
          next [question_key, key]                
        end
      end
      raise "#{validation_hsh['fieldKey']} depends_on: can't find key '#{question_key}_#{option_key}'."
    }.group_by(&:first).transform_values{ _1.map(&:last) }
end

#float_question(question) ⇒ Object



144
145
146
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 144

def float_question(question)
  number_question(question)
end


46
47
48
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 46

def footer
  handle_html(@questionnaire.footer, type: :prose, v1_markdown: false)
end

#handle_html(html, type: :simple, v1_markdown: true) ⇒ Object



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 366

def handle_html(html, type: :simple, v1_markdown: true)
  if layout_version == :v2
    case type
    when :simple
      html_sanitizer.sanitize(html, tags: %w[strong em sup sub br span img], attributes: %w[class src alt width height])
    when :question_description
      html_sanitizer.sanitize(html, tags: %w[strong em b i u sup sub pre blockquote p span br ul ol li a img], attributes: %w[class href target src alt width height])
    when :prose
      html_sanitizer.sanitize(html, tags: %w[strong em b i u sup sub pre blockquote p span br ul ol li a img h1 h2 h3 h4 hr], attributes: %w[href class target  src alt width height])
    end
  elsif v1_markdown
    Quby::Compiler::MarkdownParser.new(html).to_html
  else
    html
  end
end

#hidden_question(question) ⇒ Object

deprecated



140
141
142
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 140

def hidden_question(question)
  nil
end

#html_sanitizerObject



383
384
385
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 383

def html_sanitizer
  @html_sanitize ||= Rails::HTML5::SafeListSanitizer.new
end

#info_block_item(info_block) ⇒ Object



97
98
99
100
101
102
103
104
105
106
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 97

def info_block_item(info_block)
  {
    type: 'info',
    subtype: info_block.subtype,
    key: info_block.key,
    html: handle_html(info_block.html, type: :prose, v1_markdown: false),
    startOpen: info_block.start_open,
    items: info_block.items.map { panel_item(_1) }.compact
  }
end

#inner_title_as_json(option, idx) ⇒ Object



276
277
278
279
280
281
282
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 276

def inner_title_as_json(option, idx)
  {
    type: 'html',
    key: option.key,
    html: handle_html(option.description)
  }
end

#integer_question(question) ⇒ Object



148
149
150
151
152
153
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 148

def integer_question(question)
  {
    **number_question(question),
    **split_to_units_question(question),
  }.compact
end

#number_question(question) ⇒ Object



155
156
157
158
159
160
161
162
163
164
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 155

def number_question(question)
  {
    **base_question(question),
    **slider_question(question),
    minimum: question.minimum,
    maximum: question.maximum,
    size: size(question),
    unit: question.as != :slider && question.unit,
  }.compact
end

#option_as_json(option) ⇒ Object



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 284

def option_as_json(option)
  label = option.label || option.description
  label = handle_html(label) if option.question.type != :select
  description = !option.label ? nil : option.description
  description  = handle_html(description) if option.question.type != :select
  {
    type: 'option',
    key: option.key,
    value: option.question.type != :check_box && option.value,
    label:,
    description:,
    questions: option.question.type != :select ? option.questions.map{ question(_1) } : nil,
    hidden: option.hidden.presence,
    viewId: option.view_id
}.compact
end

#panel(panel) ⇒ Object



50
51
52
53
54
55
56
57
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 50

def panel(panel)
  {
    key: panel.key,
    title: panel.title,
    items: panel.items.map { panel_item(_1) }.compact,
    questionKeys: question_keys_for_panel(panel) # added instead of calculated in js, so we can validate completeness.
  }.compact
end

#panel_item(item) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 59

def panel_item(item)
  case item
  when Quby::Compiler::Entities::Text
    { type: 'html', key: item.key, html: handle_html(item.html, type: :prose, v1_markdown: false) }
  when Quby::Compiler::Entities::Question
    return if item.table # things inside a table are added to the table, AND ALSO to the panel. skip them.
    { type: 'question', key: item.key }
  when Quby::Compiler::Entities::InfoBlock
    info_block_item(item)
  when Quby::Compiler::Entities::Table
    { type: "table" }
  end
end

#question(question) ⇒ Object



114
115
116
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 114

def question(question)
  send(:"#{question_type(question)}_question", question)
end

#question_keys_for_panel(panel) ⇒ Object



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 73

def question_keys_for_panel(panel)
  panel.items.flat_map do |item|
    case item
    when Quby::Compiler::Entities::Question
      question_keys_for_question(item)
    when Quby::Compiler::Entities::InfoBlock, Quby::Compiler::Entities::Table
      item.items.flat_map do |nested_item|
        next unless nested_item.is_a?(Quby::Compiler::Entities::Question)
        question_keys_for_question(nested_item)
      end
    else
      []
    end
  end.compact
end

#question_keys_for_question(question) ⇒ Object



89
90
91
92
93
94
95
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 89

def question_keys_for_question(question)
  keys = [question.key]
  keys << question.title_question.key if question.title_question
  keys + question.options&.flat_map do |option|
    option.questions&.map(&:key) # We only allow subquestions one level deep.
  end
end

#question_type(question) ⇒ Object



248
249
250
251
252
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 248

def question_type(question)
  {
    date: 'date_parts',
  }[question.type] || question.type
end

#questionsObject



108
109
110
111
112
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 108

def questions
  fields.question_hash \
    .to_h { |k, question| [k, question(question)] } \
    .compact
end

#radio_question(question) ⇒ Object



166
167
168
169
170
171
172
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 166

def radio_question(question)
  {
    **base_question(question),
    children: children(question),
    showValues: [true, :all].include?(question.show_values),
}.compact
end

#scale_question(question) ⇒ Object



174
175
176
177
178
179
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 174

def scale_question(question)
  {
    **radio_question(question),
    presentation: :horizontal
  }
end

#select_question(question) ⇒ Object



181
182
183
184
185
186
187
188
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 181

def select_question(question)
  placeholder = question.all_options.find(&:placeholder)
  {
    **base_question(question),
    children: children(question),
    placeholder: placeholder&.label || placeholder&.description,
  }.compact
end

#size(question) ⇒ Object



254
255
256
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 254

def size(question)
  question.size.presence && Integer(question.size) # 2022-11: 4k string and 7k integer
end

#slider_question(question) ⇒ Object



229
230
231
232
233
234
235
236
237
238
239
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 229

def slider_question(question)
  return {} unless question.as == :slider

  {
    step: question.step,
    defaultPosition: question.default_position.is_a?(Numeric) ? question.default_position : question.minimum,
    startThumbHidden: question.default_position == :hidden,
    valueTooltip: question.input_data[:value_tooltip] || false,
    labels: question.labels,
  }
end

#split_to_units_question(question) ⇒ Object



241
242
243
244
245
246
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 241

def split_to_units_question(question)
  {
    units: question.units,
    conversions: question.conversions,
  }
end

#string_question(question) ⇒ Object



190
191
192
193
194
195
196
197
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 190

def string_question(question)
  {
    **base_question(question),
    setsTextvar: question.sets_textvar,
    size: size(question),
    unit: question.as != :slider && question.unit,
}.compact
end

#textarea_question(question) ⇒ Object



199
200
201
202
203
204
205
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 199

def textarea_question(question)
  {
    **base_question(question),
    autocomplete: question.autocomplete,
    lines: question.lines,
  }.compact
end

#textvarsObject



301
302
303
304
305
306
307
308
309
310
311
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 301

def textvars
  @questionnaire.textvars.to_h { |key, textvar|
    [
      key,
      {
        key: textvar.key,
        default: textvar.default,
      }
    ]
  }
end

#translationsObject



328
329
330
331
332
333
334
335
336
337
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 328

def translations
  @translations.clone \
    .transform_values { |translations|
      translations.clone.delete_if { |key, _v|
        key.ends_with?("context_free_title") \
        || key.ends_with?("context_free_description") \
        || key == "short_description"
      }
    }
end

#validate_all_questions_in_a_panel_question_keys(json) ⇒ Object

safeguard that question_keys_for_panel is considering all options.



39
40
41
42
43
44
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 39

def validate_all_questions_in_a_panel_question_keys(json)
  missings = @questionnaire.question_hash.values.map(&:key) - json[:panels].flat_map{_1[:questionKeys]}
  return if missings.empty?
  
  raise "Not all questions are listed in a panel['questionKeys']: #{missings.join(",")}."
end

#validationsObject



313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb', line 313

def validations
  @questionnaire.validations.map do |validation|
    validation.config.compact
      .transform_keys{ _1.to_s.camelize(:lower) }
      .tap { |validation_hsh|
        # otherwise ruby will put a (?-mix around the regex, which js errors on.
        validation_hsh['matcher'] = validation_hsh['matcher'].source.to_s  if validation_hsh['matcher']
        validation_hsh['type'] = 'minimum_date' if validation_hsh['type'] == :minimum && validation_hsh['subtype'] == :date
        validation_hsh['type'] = 'maximum_date' if validation_hsh['type'] == :maximum && validation_hsh['subtype'] == :date
        validation_hsh['dependsOnQuestions'] = depends_on(validation_hsh) if validation_hsh['type'] == :requires_answer
        validation_hsh['dependsOnOptions'] = depends_on_options(validation_hsh) if validation_hsh['type'] == :requires_answer
      }.compact.as_json
  end
end