Skip to content

Commit 65a878b

Browse files
committed
feat: add UI based form builder
1 parent 96ae0de commit 65a878b

9 files changed

Lines changed: 673 additions & 20 deletions

src/builder_page.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# builder_page.py
2+
#
3+
# Copyright 2025 Aryan Kaushik
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# SPDX-License-Identifier: GPL-3.0-or-later
11+
12+
import os
13+
14+
from gi.repository import Adw, Gtk, Gio
15+
16+
from .form_model import FormField, FormModel
17+
from .field_editor_row import FieldEditorRow
18+
from .field_type_dialog import FieldTypeDialog
19+
20+
21+
@Gtk.Template(resource_path="/in/aryank/openforms/builder_page.ui")
22+
class BuilderPage(Gtk.Box):
23+
"""
24+
The GUI Form Builder page.
25+
Users can create or edit a form visually; the result is serialised
26+
to JSON (same schema as hand-written configs) when they hit Save.
27+
28+
The page is opened as a new tab from FormConfig, just like FormPage.
29+
After saving, the produced JSON path is passed back so the tab title
30+
updates and the file can be immediately used.
31+
"""
32+
33+
__gtype_name__ = "BuilderPage"
34+
35+
# Template children (defined in builder_page.ui)
36+
builder_toast_overlay: Adw.ToastOverlay = Gtk.Template.Child()
37+
form_name_row: Adw.EntryRow = Gtk.Template.Child()
38+
fields_group: Adw.PreferencesGroup = Gtk.Template.Child()
39+
40+
def __init__(self, **kwargs):
41+
super().__init__(**kwargs)
42+
self.set_hexpand(True)
43+
self.set_vexpand(True)
44+
45+
self.model = FormModel()
46+
self._rows: list[FieldEditorRow] = []
47+
48+
# Wire the form-name entry (declared in UI template)
49+
self.form_name_row.connect("changed", self._on_form_name_changed)
50+
51+
def set_page(self, page):
52+
"""Called by FormConfig after the tab is created, same pattern as FormPage."""
53+
self.page = page
54+
55+
def load_from_model(self, model: FormModel):
56+
"""Populate the builder from an existing FormModel (for editing a saved form)."""
57+
self.model = model
58+
self.form_name_row.set_text(model.form_name)
59+
for ff in model.fields:
60+
self._append_row(ff)
61+
62+
@Gtk.Template.Callback()
63+
def on_add_field_clicked(self, *_):
64+
dialog = FieldTypeDialog(self._add_field_of_type)
65+
dialog.present(self.get_root())
66+
67+
@Gtk.Template.Callback()
68+
def on_save_clicked(self, *_):
69+
file_dialog = Gtk.FileDialog()
70+
file_dialog.set_title("Save Form Config")
71+
safe_name = (self.model.form_name or "form").lower().replace(" ", "_")
72+
file_dialog.set_initial_name(safe_name + ".json")
73+
74+
json_filter = Gtk.FileFilter()
75+
json_filter.set_name("JSON files")
76+
json_filter.add_mime_type("application/json")
77+
json_filter.add_pattern("*.json")
78+
filters = Gio.ListStore.new(Gtk.FileFilter)
79+
filters.append(json_filter)
80+
file_dialog.set_filters(filters)
81+
82+
file_dialog.save(self.get_root(), None, self._on_save_finish)
83+
84+
def _on_form_name_changed(self, row):
85+
self.model.form_name = row.get_text()
86+
# Keep the tab title in sync if we have a page reference
87+
if hasattr(self, "page") and self.page and hasattr(self.page, "tab_page"):
88+
title = row.get_text() or "New Form"
89+
self.page.tab_page.set_title(f"✏ {title}")
90+
91+
def _add_field_of_type(self, ftype: str):
92+
ff = FormField(type=ftype, label="")
93+
self.model.fields.append(ff)
94+
self._append_row(ff, expanded=True)
95+
96+
def _append_row(self, ff: FormField, expanded: bool = False):
97+
row = FieldEditorRow(
98+
ff,
99+
on_delete=self._delete_row,
100+
on_move_up=self._move_up,
101+
on_move_down=self._move_down,
102+
)
103+
self._rows.append(row)
104+
self.fields_group.add(row)
105+
if expanded:
106+
row.set_expanded(True)
107+
108+
def _delete_row(self, row: FieldEditorRow):
109+
self.model.fields.remove(row.form_field)
110+
self._rows.remove(row)
111+
self.fields_group.remove(row)
112+
113+
def _move_up(self, row: FieldEditorRow):
114+
idx = self._rows.index(row)
115+
if idx > 0:
116+
self._swap(idx, idx - 1)
117+
118+
def _move_down(self, row: FieldEditorRow):
119+
idx = self._rows.index(row)
120+
if idx < len(self._rows) - 1:
121+
self._swap(idx, idx + 1)
122+
123+
def _swap(self, i: int, j: int):
124+
# Swap in model
125+
self.model.fields[i], self.model.fields[j] = (
126+
self.model.fields[j],
127+
self.model.fields[i],
128+
)
129+
# Swap in row list, then rebuild visual order
130+
self._rows[i], self._rows[j] = self._rows[j], self._rows[i]
131+
for r in self._rows:
132+
self.fields_group.remove(r)
133+
for r in self._rows:
134+
self.fields_group.add(r)
135+
136+
def _on_save_finish(self, dialog, result):
137+
try:
138+
file = dialog.save_finish(result)
139+
except Exception:
140+
# User cancelled
141+
return
142+
143+
path = file.get_path()
144+
if not path:
145+
self._show_toast("Could not determine save path")
146+
return
147+
148+
try:
149+
with open(path, "w", encoding="utf-8") as f:
150+
f.write(self.model.to_json())
151+
except OSError as e:
152+
self._show_toast(f"Save failed: {e}")
153+
return
154+
155+
self._show_toast(f"Saved to {path}")
156+
157+
# Update tab title to reflect saved file name
158+
if hasattr(self, "page") and self.page and hasattr(self.page, "tab_page"):
159+
self.page.tab_page.set_title(os.path.basename(path))
160+
161+
def _show_toast(self, message: str, timeout: int = 3):
162+
toast = Adw.Toast.new(message)
163+
toast.set_timeout(timeout)
164+
self.builder_toast_overlay.add_toast(toast)

src/builder_page.ui

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<interface>
3+
<requires lib="gtk" version="4.0"/>
4+
<requires lib="Adw" version="1.0"/>
5+
<template class="BuilderPage" parent="GtkBox">
6+
<property name="orientation">vertical</property>
7+
<child>
8+
<object class="AdwToastOverlay" id="builder_toast_overlay">
9+
<property name="vexpand">True</property>
10+
<property name="hexpand">True</property>
11+
<child>
12+
<object class="GtkBox">
13+
<property name="orientation">vertical</property>
14+
<!-- Scrollable content area -->
15+
<child>
16+
<object class="GtkScrolledWindow">
17+
<property name="vexpand">True</property>
18+
<property name="hexpand">True</property>
19+
<child>
20+
<object class="AdwClamp">
21+
<property name="maximum-size">600</property>
22+
<property name="tightening-threshold">400</property>
23+
<child>
24+
<object class="GtkBox">
25+
<property name="orientation">vertical</property>
26+
<property name="spacing">18</property>
27+
<property name="margin-top">24</property>
28+
<property name="margin-bottom">24</property>
29+
<property name="margin-start">18</property>
30+
<property name="margin-end">18</property>
31+
<child>
32+
<object class="AdwPreferencesGroup">
33+
<property name="title">Form</property>
34+
<child>
35+
<object class="AdwEntryRow" id="form_name_row">
36+
<property name="title">Form Name</property>
37+
</object>
38+
</child>
39+
</object>
40+
</child>
41+
<!-- Fields list — populated programmatically -->
42+
<child>
43+
<object class="AdwPreferencesGroup" id="fields_group">
44+
<property name="title">Fields</property>
45+
</object>
46+
</child>
47+
<child>
48+
<object class="GtkButton">
49+
<property name="halign">fill</property>
50+
<property name="css-classes">pill suggested-action</property>
51+
<signal name="clicked" handler="on_add_field_clicked"/>
52+
<property name="child">
53+
<object class="AdwButtonContent">
54+
<property name="icon-name">list-add-symbolic</property>
55+
<property name="label" translatable="yes">Add Field</property>
56+
</object>
57+
</property>
58+
</object>
59+
</child>
60+
61+
</object>
62+
</child>
63+
</object>
64+
</child>
65+
</object>
66+
</child>
67+
<child>
68+
<object class="GtkBox">
69+
<property name="orientation">vertical</property>
70+
<property name="margin-top">6</property>
71+
<property name="margin-bottom">12</property>
72+
<property name="margin-start">18</property>
73+
<property name="margin-end">18</property>
74+
<child>
75+
<object class="GtkSeparator">
76+
<property name="margin-bottom">10</property>
77+
</object>
78+
</child>
79+
<child>
80+
<object class="GtkButton">
81+
<property name="label" translatable="yes">Save Form</property>
82+
<property name="halign">center</property>
83+
<property name="css-classes">pill</property>
84+
<signal name="clicked" handler="on_save_clicked"/>
85+
</object>
86+
</child>
87+
</object>
88+
</child>
89+
</object>
90+
</child>
91+
</object>
92+
</child>
93+
</template>
94+
</interface>

0 commit comments

Comments
 (0)