codeforms 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codeforms/export.py CHANGED
@@ -1,18 +1,39 @@
1
1
  from enum import Enum
2
+ from typing import Any, Dict
3
+
4
+ from codeforms.fields import (
5
+ CheckboxField,
6
+ CheckboxGroupField,
7
+ DateField,
8
+ EmailField,
9
+ FieldGroup,
10
+ FileField,
11
+ FormFieldBase,
12
+ FormStep,
13
+ HiddenField,
14
+ ListField,
15
+ ObjectListField,
16
+ NumberField,
17
+ RadioField,
18
+ SelectField,
19
+ TextareaField,
20
+ TextField,
21
+ UrlField,
22
+ )
2
23
  from codeforms.forms import Form
3
- from codeforms.fields import FormFieldBase, FieldGroup, FormStep
4
24
  from codeforms.i18n import t
5
25
 
6
26
 
7
27
  class ExportFormat(Enum):
8
- HTML = 'html'
9
- BOOTSTRAP4 = 'html_bootstrap4'
10
- BOOTSTRAP5 = 'html_bootstrap5'
28
+ HTML = "html"
29
+ BOOTSTRAP4 = "html_bootstrap4"
30
+ BOOTSTRAP5 = "html_bootstrap5"
31
+ JSON_SCHEMA = "json_schema"
11
32
 
12
33
 
13
34
  def generate_validation_code(form, output_format: str) -> str:
14
35
  """Genera el código de validación en Javascript"""
15
- if output_format == 'html':
36
+ if output_format == "html":
16
37
  validation_code = f"""
17
38
  <script>
18
39
  function validate_{form.name}(form) {{
@@ -97,67 +118,75 @@ def js_generate_field_validations(form) -> str:
97
118
 
98
119
  return "\n".join(validations)
99
120
 
121
+
100
122
  def field_exporter(field: FormFieldBase, output_format: str, **kwargs) -> str:
101
123
  """Exporta un campo individual al formato especificado"""
102
- if output_format == 'html':
124
+ if output_format == "html":
103
125
  return field_to_html(field, kwargs=kwargs)
104
126
  return ""
105
127
 
106
128
 
107
129
  def group_exporter(group, output_format: str, **kwargs) -> str:
108
130
  """Exporta un grupo de campos al formato especificado"""
109
- if output_format == 'html':
131
+ if output_format == "html":
110
132
  return group_to_html(group, kwargs=kwargs)
111
133
  return ""
112
134
 
113
135
 
114
136
  def step_exporter(step, output_format: str, **kwargs) -> str:
115
137
  """Exporta un paso de formulario (wizard) al formato especificado"""
116
- if output_format in ('html', ExportFormat.HTML.value,
117
- ExportFormat.BOOTSTRAP4.value,
118
- ExportFormat.BOOTSTRAP5.value):
119
- actual_kwargs = kwargs.get('kwargs', kwargs)
120
- actual_kwargs['output_format'] = output_format
138
+ if output_format in (
139
+ "html",
140
+ ExportFormat.HTML.value,
141
+ ExportFormat.BOOTSTRAP4.value,
142
+ ExportFormat.BOOTSTRAP5.value,
143
+ ):
144
+ actual_kwargs = kwargs.get("kwargs", kwargs)
145
+ actual_kwargs["output_format"] = output_format
121
146
  return step_to_html(step, **actual_kwargs)
122
147
  return ""
123
148
 
124
149
 
125
150
  def group_to_html(group, **kwargs) -> str:
126
151
  """Genera la representación HTML del grupo de campos usando fieldset y legend"""
127
- output_format = kwargs.get('output_format', ExportFormat.HTML.value)
128
- is_bootstrap = output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value]
129
-
152
+ output_format = kwargs.get("output_format", ExportFormat.HTML.value)
153
+ is_bootstrap = output_format in [
154
+ ExportFormat.BOOTSTRAP4.value,
155
+ ExportFormat.BOOTSTRAP5.value,
156
+ ]
157
+
130
158
  # Clases CSS para el fieldset
131
- fieldset_class = f"mb-4 {group.css_classes or ''}".strip() if is_bootstrap else group.css_classes or ""
159
+ fieldset_class = (
160
+ f"mb-4 {group.css_classes or ''}".strip()
161
+ if is_bootstrap
162
+ else group.css_classes or ""
163
+ )
132
164
  legend_class = "h5 mb-3" if is_bootstrap else ""
133
-
165
+
134
166
  # Atributos del fieldset
135
- fieldset_attrs = {
136
- "id": f"group_{group.id}",
137
- "class": fieldset_class
138
- }
139
-
167
+ fieldset_attrs = {"id": f"group_{group.id}", "class": fieldset_class}
168
+
140
169
  # Agregar atributos personalizados del grupo
141
170
  fieldset_attrs.update(group.attributes)
142
-
171
+
143
172
  # Generar string de atributos
144
173
  attrs_str = " ".join(f'{k}="{v}"' for k, v in fieldset_attrs.items() if v)
145
-
174
+
146
175
  # Generar HTML de los campos dentro del grupo
147
176
  fields_html = "\n".join(field_to_html(field, **kwargs) for field in group.fields)
148
-
177
+
149
178
  # Generar descripción si existe
150
179
  description_html = ""
151
180
  if group.description:
152
181
  desc_class = "text-muted small mb-3" if is_bootstrap else "group-description"
153
182
  description_html = f'<p class="{desc_class}">{group.description}</p>'
154
-
183
+
155
184
  # Construir el HTML del fieldset
156
- html = f'<fieldset {attrs_str}>'
185
+ html = f"<fieldset {attrs_str}>"
157
186
  html += f'<legend class="{legend_class}">{group.title}</legend>'
158
187
  html += description_html
159
188
  html += fields_html
160
- html += '</fieldset>'
189
+ html += "</fieldset>"
161
190
 
162
191
  return html
163
192
 
@@ -167,11 +196,18 @@ def step_to_html(step, **kwargs) -> str:
167
196
 
168
197
  Diferente de FieldGroup (que usa <fieldset>) para distinguir semánticamente.
169
198
  """
170
- output_format = kwargs.get('output_format', ExportFormat.HTML.value)
171
- is_bootstrap = output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value]
199
+ output_format = kwargs.get("output_format", ExportFormat.HTML.value)
200
+ is_bootstrap = output_format in [
201
+ ExportFormat.BOOTSTRAP4.value,
202
+ ExportFormat.BOOTSTRAP5.value,
203
+ ]
172
204
 
173
205
  # Clases CSS para el section
174
- step_class = f"form-step mb-4 {step.css_classes or ''}".strip() if is_bootstrap else f"form-step {step.css_classes or ''}".strip()
206
+ step_class = (
207
+ f"form-step mb-4 {step.css_classes or ''}".strip()
208
+ if is_bootstrap
209
+ else f"form-step {step.css_classes or ''}".strip()
210
+ )
175
211
  title_class = "h4 mb-3" if is_bootstrap else "step-title"
176
212
 
177
213
  # Atributos del section
@@ -180,7 +216,7 @@ def step_to_html(step, **kwargs) -> str:
180
216
  "class": step_class,
181
217
  "data-step": "true",
182
218
  "data-validation-mode": step.validation_mode,
183
- "data-skippable": str(step.skippable).lower()
219
+ "data-skippable": str(step.skippable).lower(),
184
220
  }
185
221
  step_attrs.update(step.attributes)
186
222
 
@@ -201,19 +237,24 @@ def step_to_html(step, **kwargs) -> str:
201
237
  desc_class = "text-muted mb-3" if is_bootstrap else "step-description"
202
238
  description_html = f'<p class="{desc_class}">{step.description}</p>'
203
239
 
204
- html = f'<section {attrs_str}>'
240
+ html = f"<section {attrs_str}>"
205
241
  html += f'<h2 class="{title_class}">{step.title}</h2>'
206
242
  html += description_html
207
243
  html += content_html
208
- html += '</section>'
244
+ html += "</section>"
209
245
 
210
246
  return html
211
247
 
212
248
 
213
249
  def form_to_html(form: Form, **kwargs) -> str:
214
250
  """Genera el HTML completo del formulario"""
215
- output_format = kwargs.get('output_format', ExportFormat.HTML.value)
216
- form_class = "needs-validation" if output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value] else ""
251
+ output_format = kwargs.get("output_format", ExportFormat.HTML.value)
252
+ form_class = (
253
+ "needs-validation"
254
+ if output_format
255
+ in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value]
256
+ else ""
257
+ )
217
258
 
218
259
  # Detectar si es wizard
219
260
  is_wizard = any(isinstance(item, FormStep) for item in form.content)
@@ -221,10 +262,10 @@ def form_to_html(form: Form, **kwargs) -> str:
221
262
  form_class = f"{form_class} form-wizard".strip()
222
263
 
223
264
  attributes = {
224
- "id": kwargs.get('id') or str(form.id),
265
+ "id": kwargs.get("id") or str(form.id),
225
266
  "name": form.name,
226
267
  "class": f"{form_class} {form.css_classes or ''}".strip(),
227
- "enctype": kwargs.get('enctype') or "application/x-www-form-urlencoded"
268
+ "enctype": kwargs.get("enctype") or "application/x-www-form-urlencoded",
228
269
  }
229
270
  if is_wizard:
230
271
  attributes["data-wizard"] = "true"
@@ -240,11 +281,16 @@ def form_to_html(form: Form, **kwargs) -> str:
240
281
  content_html_parts.append(group_to_html(item, **kwargs))
241
282
  else: # Es un campo individual
242
283
  content_html_parts.append(field_to_html(item, **kwargs))
243
-
284
+
244
285
  content_html = "\n".join(content_html_parts)
245
-
246
- if kwargs.get('submit'):
247
- submit_class = "btn btn-primary" if output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value] else ""
286
+
287
+ if kwargs.get("submit"):
288
+ submit_class = (
289
+ "btn btn-primary"
290
+ if output_format
291
+ in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value]
292
+ else ""
293
+ )
248
294
  submit_html = f'<button type="submit" class="{submit_class}">{t("export.submit")}</button>'
249
295
  else:
250
296
  submit_html = ""
@@ -252,89 +298,104 @@ def form_to_html(form: Form, **kwargs) -> str:
252
298
  html = f"<form {attrs_str}>"
253
299
  html += f"\t{content_html}"
254
300
  html += f"\t{submit_html}"
255
- html += f"</form>"
301
+ html += "</form>"
256
302
  return html
257
303
 
258
304
 
259
305
  def field_to_html(field: FormFieldBase, **kwargs) -> str:
260
306
  """Genera la representación HTML del campo"""
261
- output_format = kwargs.get('output_format', ExportFormat.HTML.value)
262
- is_bootstrap = output_format in [ExportFormat.BOOTSTRAP4.value, ExportFormat.BOOTSTRAP5.value]
263
-
307
+ output_format = kwargs.get("output_format", ExportFormat.HTML.value)
308
+ is_bootstrap = output_format in [
309
+ ExportFormat.BOOTSTRAP4.value,
310
+ ExportFormat.BOOTSTRAP5.value,
311
+ ]
312
+
264
313
  # Clases base para Bootstrap 4/5
265
314
  if is_bootstrap:
266
315
  base_input_class = "form-control"
267
- form_group_class = "mb-3" if output_format == ExportFormat.BOOTSTRAP5.value else "form-group"
316
+ form_group_class = (
317
+ "mb-3" if output_format == ExportFormat.BOOTSTRAP5.value else "form-group"
318
+ )
268
319
  help_text_class = "form-text" # Bootstrap 5 removed text-muted
269
320
  else:
270
321
  base_input_class = ""
271
322
  form_group_class = "form-field"
272
323
  help_text_class = "help-text"
273
324
 
274
- skip_label = ['hidden']
275
-
276
- label_html = ''
325
+ skip_label = ["hidden"]
326
+
327
+ label_html = ""
277
328
  if field.field_type_value not in skip_label:
278
329
  label_class = "form-label" if is_bootstrap else ""
279
- label_html = f'<label class="{label_class}" for="{field.id}">{field.label}</label>'
280
-
281
- help_html = f'<small class="{help_text_class}">{field.help_text}</small>' if field.help_text else ""
330
+ label_html = (
331
+ f'<label class="{label_class}" for="{field.id}">{field.label}</label>'
332
+ )
333
+
334
+ help_html = (
335
+ f'<small class="{help_text_class}">{field.help_text}</small>'
336
+ if field.help_text
337
+ else ""
338
+ )
282
339
 
283
340
  # Manejar campos SELECT de manera especial
284
- if field.field_type_value == 'select':
341
+ if field.field_type_value == "select":
285
342
  select_attrs = {
286
343
  "id": str(field.id),
287
344
  "name": field.name,
288
345
  "class": f"{base_input_class} {field.css_classes or ''}".strip(),
289
346
  }
290
-
347
+
291
348
  if field.required:
292
349
  select_attrs["required"] = "required"
293
-
294
- if hasattr(field, 'multiple') and field.multiple:
350
+
351
+ if hasattr(field, "multiple") and field.multiple:
295
352
  select_attrs["multiple"] = "multiple"
296
-
353
+
297
354
  # Agregar atributos personalizados
298
355
  select_attrs.update(field.attributes)
299
-
356
+
300
357
  attrs_str = " ".join(f'{k}="{v}"' for k, v in select_attrs.items() if v)
301
-
358
+
302
359
  # Generar opciones
303
360
  options_html = ""
304
- if hasattr(field, 'options'):
361
+ if hasattr(field, "options"):
305
362
  for option in field.options:
306
363
  selected = 'selected="selected"' if option.selected else ""
307
- options_html += f'<option value="{option.value}" {selected}>{option.label}</option>'
308
-
309
- input_html = f'<select {attrs_str}>{options_html}</select>'
310
-
364
+ options_html += (
365
+ f'<option value="{option.value}" {selected}>{option.label}</option>'
366
+ )
367
+
368
+ input_html = f"<select {attrs_str}>{options_html}</select>"
369
+
311
370
  # Manejar campos RADIO de manera especial
312
- elif field.field_type_value == 'radio':
371
+ elif field.field_type_value == "radio":
313
372
  radio_html_parts = []
314
- if hasattr(field, 'options'):
373
+ if hasattr(field, "options"):
315
374
  for option in field.options:
316
375
  radio_attrs = {
317
376
  "id": f"{field.id}_{option.value}",
318
377
  "name": field.name,
319
378
  "type": "radio",
320
379
  "value": option.value,
321
- "class": field.css_classes or ""
380
+ "class": field.css_classes or "",
322
381
  }
323
-
382
+
324
383
  if option.selected:
325
384
  radio_attrs["checked"] = "checked"
326
-
385
+
327
386
  if field.required:
328
387
  radio_attrs["required"] = "required"
329
-
388
+
330
389
  attrs_str = " ".join(f'{k}="{v}"' for k, v in radio_attrs.items() if v)
331
- radio_label = f'<label for="{field.id}_{option.value}">{option.label}</label>'
332
- radio_html_parts.append(f'<input {attrs_str}>{radio_label}')
333
-
334
- input_html = '<div class="radio-group">' + ''.join(radio_html_parts) + '</div>'
335
-
390
+ radio_label = (
391
+ f'<label for="{field.id}_{option.value}">{option.label}</label>'
392
+ )
393
+ radio_html_parts.append(f"<input {attrs_str}>{radio_label}")
394
+
395
+ input_html = '<div class="radio-group">' + "".join(radio_html_parts) + "</div>"
396
+
336
397
  # Manejar campos CHECKBOX con opciones múltiples
337
- elif field.field_type_value == 'checkbox' and hasattr(field, 'options'):
398
+ elif field.field_type_value == "checkbox" and hasattr(field, "options"):
338
399
  checkbox_html_parts = []
339
400
  for option in field.options:
340
401
  checkbox_attrs = {
@@ -342,21 +403,25 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
342
403
  "name": field.name,
343
404
  "type": "checkbox",
344
405
  "value": option.value,
345
- "class": field.css_classes or ""
406
+ "class": field.css_classes or "",
346
407
  }
347
-
408
+
348
409
  if option.selected:
349
410
  checkbox_attrs["checked"] = "checked"
350
-
411
+
351
412
  if field.required:
352
413
  checkbox_attrs["required"] = "required"
353
-
414
+
354
415
  attrs_str = " ".join(f'{k}="{v}"' for k, v in checkbox_attrs.items() if v)
355
- checkbox_label = f'<label for="{field.id}_{option.value}">{option.label}</label>'
356
- checkbox_html_parts.append(f'<input {attrs_str}>{checkbox_label}')
357
-
358
- input_html = '<div class="checkbox-group">' + ''.join(checkbox_html_parts) + '</div>'
359
-
416
+ checkbox_label = (
417
+ f'<label for="{field.id}_{option.value}">{option.label}</label>'
418
+ )
419
+ checkbox_html_parts.append(f"<input {attrs_str}>{checkbox_label}")
420
+
421
+ input_html = (
422
+ '<div class="checkbox-group">' + "".join(checkbox_html_parts) + "</div>"
423
+ )
424
+
360
425
  # Manejar campos normales (input)
361
426
  else:
362
427
  attributes = {
@@ -366,18 +431,22 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
366
431
  "class": f"{base_input_class} {field.css_classes or ''}".strip(),
367
432
  "placeholder": field.placeholder or "",
368
433
  }
369
-
370
- if hasattr(field, 'value'):
371
- attributes["value"] = getattr(field, 'value')
434
+
435
+ if hasattr(field, "value"):
436
+ attributes["value"] = field.value
372
437
 
373
438
  if field.required:
374
439
  attributes["required"] = "required"
375
440
 
376
441
  if field.default_value is not None:
377
442
  attributes["value"] = str(field.default_value)
378
-
443
+
379
444
  # Para checkbox simple, manejar el atributo checked
380
- if field.field_type_value == 'checkbox' and hasattr(field, 'checked') and field.checked:
445
+ if (
446
+ field.field_type_value == "checkbox"
447
+ and hasattr(field, "checked")
448
+ and field.checked
449
+ ):
381
450
  attributes["checked"] = "checked"
382
451
 
383
452
  # Agregar atributos personalizados
@@ -385,16 +454,200 @@ def field_to_html(field: FormFieldBase, **kwargs) -> str:
385
454
 
386
455
  # Convertir atributos a string
387
456
  attrs_str = " ".join(f'{k}="{v}"' for k, v in attributes.items() if v)
388
- input_html = f'<input {attrs_str}>'
457
+ input_html = f"<input {attrs_str}>"
458
+
459
+ return (
460
+ f"""<div class="{form_group_class}">{label_html}{input_html}{help_html}</div>"""
461
+ )
389
462
 
390
- return f"""<div class="{form_group_class}">{label_html}{input_html}{help_html}</div>"""
391
463
 
392
464
  def exporter(form: Form, output_format: str, **kwargs) -> dict:
393
- export_result = {'format': output_format}
394
- if output_format in [format.value for format in ExportFormat]:
395
- actual_kwargs = kwargs.get('kwargs', kwargs)
396
- actual_kwargs['output_format'] = output_format
397
- export_result['output'] = form_to_html(form, **actual_kwargs)
398
- export_result['javascript_validation_code'] = generate_validation_code(form, output_format)
399
-
400
- return export_result
465
+ export_result = {"format": output_format}
466
+
467
+ if output_format == ExportFormat.JSON_SCHEMA.value:
468
+ export_result["output"] = form_to_json_schema(form)
469
+ elif output_format in [fmt.value for fmt in ExportFormat]:
470
+ actual_kwargs = kwargs.get("kwargs", kwargs)
471
+ actual_kwargs["output_format"] = output_format
472
+ export_result["output"] = form_to_html(form, **actual_kwargs)
473
+ export_result["javascript_validation_code"] = generate_validation_code(
474
+ form, output_format
475
+ )
476
+
477
+ return export_result
478
+
479
+
480
+ _LIST_ITEM_TYPE_MAP: Dict[str, str] = {
481
+ "text": "string",
482
+ "number": "number",
483
+ "email": "string",
484
+ "url": "string",
485
+ "date": "string",
486
+ }
487
+
488
+
489
+ def _object_list_item_schema(field: ObjectListField) -> Dict[str, Any]:
490
+ properties: Dict[str, Any] = {}
491
+ required = []
492
+
493
+ for subfield in field.fields:
494
+ properties[subfield.name] = _field_to_json_schema_property(subfield)
495
+ if subfield.required:
496
+ required.append(subfield.name)
497
+
498
+ item_schema: Dict[str, Any] = {
499
+ "type": "object",
500
+ "properties": properties,
501
+ "additionalProperties": False,
502
+ }
503
+ if required:
504
+ item_schema["required"] = required
505
+
506
+ return item_schema
507
+
508
+
509
+ def _field_to_json_schema_property(field: FormFieldBase) -> Dict[str, Any]:
510
+ """Convert a single form field to a JSON Schema property definition."""
511
+ prop: Dict[str, Any] = {}
512
+
513
+ if isinstance(field, SelectField):
514
+ enum_values = [opt.value for opt in field.options]
515
+ if field.multiple:
516
+ prop["type"] = "array"
517
+ prop["items"] = {"type": "string", "enum": enum_values}
518
+ prop["uniqueItems"] = True
519
+ if field.min_selected is not None:
520
+ prop["minItems"] = field.min_selected
521
+ if field.max_selected is not None:
522
+ prop["maxItems"] = field.max_selected
523
+ else:
524
+ prop["type"] = "string"
525
+ prop["enum"] = enum_values
526
+
527
+ elif isinstance(field, RadioField):
528
+ prop["type"] = "string"
529
+ prop["enum"] = [opt.value for opt in field.options]
530
+
531
+ elif isinstance(field, CheckboxGroupField):
532
+ prop["type"] = "array"
533
+ prop["items"] = {
534
+ "type": "string",
535
+ "enum": [opt.value for opt in field.options],
536
+ }
537
+ prop["uniqueItems"] = True
538
+
539
+ elif isinstance(field, CheckboxField):
540
+ prop["type"] = "boolean"
541
+
542
+ elif isinstance(field, EmailField):
543
+ prop["type"] = "string"
544
+ prop["format"] = "email"
545
+
546
+ elif isinstance(field, NumberField):
547
+ prop["type"] = "number"
548
+ if field.min_value is not None:
549
+ prop["minimum"] = field.min_value
550
+ if field.max_value is not None:
551
+ prop["maximum"] = field.max_value
552
+ if field.step is not None:
553
+ prop["multipleOf"] = field.step
554
+
555
+ elif isinstance(field, DateField):
556
+ prop["type"] = "string"
557
+ prop["format"] = "date"
558
+
559
+ elif isinstance(field, FileField):
560
+ if field.multiple:
561
+ prop["type"] = "array"
562
+ prop["items"] = {"type": "string", "contentEncoding": "base64"}
563
+ else:
564
+ prop["type"] = "string"
565
+ prop["contentEncoding"] = "base64"
566
+
567
+ elif isinstance(field, HiddenField):
568
+ prop["type"] = "string"
569
+
570
+ elif isinstance(field, UrlField):
571
+ prop["type"] = "string"
572
+ prop["format"] = "uri"
573
+ if field.minlength is not None:
574
+ prop["minLength"] = field.minlength
575
+ if field.maxlength is not None:
576
+ prop["maxLength"] = field.maxlength
577
+
578
+ elif isinstance(field, TextareaField):
579
+ prop["type"] = "string"
580
+ if field.minlength is not None:
581
+ prop["minLength"] = field.minlength
582
+ if field.maxlength is not None:
583
+ prop["maxLength"] = field.maxlength
584
+
585
+ elif isinstance(field, ListField):
586
+ item_type = _LIST_ITEM_TYPE_MAP.get(field.item_type, "string")
587
+ prop["type"] = "array"
588
+ prop["items"] = {"type": item_type}
589
+ if field.min_items is not None:
590
+ prop["minItems"] = field.min_items
591
+ if field.max_items is not None:
592
+ prop["maxItems"] = field.max_items
593
+
594
+ elif isinstance(field, ObjectListField):
595
+ prop["type"] = "array"
596
+ prop["items"] = _object_list_item_schema(field)
597
+ if field.min_items is not None:
598
+ prop["minItems"] = field.min_items
599
+ if field.max_items is not None:
600
+ prop["maxItems"] = field.max_items
601
+
602
+ elif isinstance(field, TextField):
603
+ prop["type"] = "string"
604
+ if field.minlength is not None:
605
+ prop["minLength"] = field.minlength
606
+ if field.maxlength is not None:
607
+ prop["maxLength"] = field.maxlength
608
+ if field.pattern is not None:
609
+ prop["pattern"] = field.pattern
610
+
611
+ else:
612
+ prop["type"] = "string"
613
+
614
+ if field.label:
615
+ prop["title"] = field.label
616
+ if field.help_text:
617
+ prop["description"] = field.help_text
618
+ if field.default_value is not None:
619
+ prop["default"] = field.default_value
620
+ if field.readonly:
621
+ prop["readOnly"] = True
622
+
623
+ return prop
624
+
625
+
626
+ def form_to_json_schema(form: Form) -> Dict[str, Any]:
627
+ """Convert a Form definition to a JSON Schema (draft-07) object.
628
+
629
+ All fields (including those inside FieldGroups and FormSteps) are
630
+ flattened into a single ``properties`` mapping via ``form.fields``.
631
+
632
+ Returns:
633
+ A Python dict representing the JSON Schema.
634
+ """
635
+ properties: Dict[str, Any] = {}
636
+ required = []
637
+
638
+ for field in form.fields:
639
+ properties[field.name] = _field_to_json_schema_property(field)
640
+ if field.required:
641
+ required.append(field.name)
642
+
643
+ schema: Dict[str, Any] = {
644
+ "$schema": "http://json-schema.org/draft-07/schema#",
645
+ "type": "object",
646
+ "title": form.name,
647
+ "properties": properties,
648
+ "additionalProperties": False,
649
+ }
650
+ if required:
651
+ schema["required"] = required
652
+
653
+ return schema