diff --git a/frm.go b/frm.go
index 5450e30..4c13fb8 100644
--- a/frm.go
+++ b/frm.go
@@ -165,6 +165,6 @@ func CollectorPathForm(ctx context.Context, formID int64, path ...string) string
if !ok {
return "/"
}
- base = filepath.Clean(fmt.Sprintf("%s/collect", base))
+ base = filepath.Clean(base)
return fmt.Sprintf("%s/%d", base, formID)
}
diff --git a/handlers/collector.go b/handlers/collector.go
new file mode 100644
index 0000000..ebc528a
--- /dev/null
+++ b/handlers/collector.go
@@ -0,0 +1,100 @@
+package handlers
+
+import (
+ "log/slog"
+ "maps"
+ "net/http"
+ "net/url"
+ "slices"
+
+ "github.com/acaloiaro/frm"
+ "github.com/acaloiaro/frm/internal"
+ "github.com/acaloiaro/frm/types"
+ "github.com/acaloiaro/frm/ui"
+)
+
+// View renders the form viewer for the collector
+func View(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ i, err := frm.Instance(ctx)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ formID, err := formID(ctx)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ f, err := internal.Q(ctx, i.DBArgs).GetForm(ctx, internal.GetFormParams{
+ WorkspaceID: i.WorkspaceID,
+ ID: *formID,
+ })
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ // Render the form collector
+ err = ui.Viewer((frm.Form)(f)).Render(ctx, w)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+}
+
+// Collect handles collector form submissions
+func Collect(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ i, err := frm.Instance(ctx)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ formID, err := formID(ctx)
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ f, err := internal.Q(ctx, i.DBArgs).GetForm(ctx, internal.GetFormParams{
+ WorkspaceID: i.WorkspaceID,
+ ID: *formID,
+ })
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ err = r.ParseForm()
+ if err != nil {
+ slog.Error("[collector] unable to parse form", "error", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ errs := validate(f, r.Form)
+ if errs.Any() {
+ slog.Info("[collector] failed validation", "errors", errs)
+ w.WriteHeader(http.StatusBadRequest)
+ } else {
+ w.Header().Add("hx-redirect", frm.CollectorPathForm(ctx, *formID))
+ w.WriteHeader(http.StatusOK)
+ }
+
+ allFields := slices.Collect(maps.Keys(f.Fields))
+ err = ui.Validation(allFields, errs).Render(ctx, w)
+ if err != nil {
+ slog.Error("[collector] error while reporting validation error", "error", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+}
+
+// validate validates forms
+func validate(f internal.Form, submission url.Values) (errs types.ValidationErrors) {
+ errs = types.ValidationErrors{}
+ for submittedFieldID := range maps.Keys(submission) {
+ field := f.Fields[submittedFieldID]
+ formFieldValue := submission[submittedFieldID]
+ if err := field.Validate(formFieldValue); err != nil {
+ errs[submittedFieldID] = err
+ }
+ }
+ return errs
+}
diff --git a/handlers/handlers.go b/handlers/handlers.go
index 9a4da05..68e80db 100644
--- a/handlers/handlers.go
+++ b/handlers/handlers.go
@@ -734,61 +734,3 @@ func toFormFieldOption(field types.FormField, options []string) types.FieldOptio
return fieldOptions
}
-
-// View renders the form viewer for the collector
-func View(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- i, err := frm.Instance(ctx)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- return
- }
- formID, err := formID(ctx)
- if err != nil {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- f, err := internal.Q(ctx, i.DBArgs).GetForm(ctx, internal.GetFormParams{
- WorkspaceID: i.WorkspaceID,
- ID: *formID,
- })
- if err != nil {
- w.WriteHeader(http.StatusNotFound)
- return
- }
-
- // Render the form collector
- err = ui.Viewer((frm.Form)(f)).Render(ctx, w)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- }
-}
-
-// Collect handles collector form submissions
-func Collect(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- i, err := frm.Instance(ctx)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- return
- }
- formID, err := formID(ctx)
- if err != nil {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- f, err := internal.Q(ctx, i.DBArgs).GetForm(ctx, internal.GetFormParams{
- WorkspaceID: i.WorkspaceID,
- ID: *formID,
- })
- if err != nil {
- w.WriteHeader(http.StatusNotFound)
- return
- }
-
- // Render the form collector
- err = ui.Viewer((frm.Form)(f)).Render(ctx, w)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- }
-}
diff --git a/static/css/styles.css b/static/css/styles.css
index a4edeab..249aa8d 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -2850,6 +2850,11 @@ input.tab:checked + .tab-content,
color: inherit;
}
+.text-red-400 {
+ --tw-text-opacity: 1;
+ color: rgb(255 153 153 / var(--tw-text-opacity));
+}
+
.text-red-500 {
--tw-text-opacity: 1;
color: rgb(255 119 119 / var(--tw-text-opacity));
diff --git a/types/types.go b/types/types.go
index cbb9cf6..7c075ac 100644
--- a/types/types.go
+++ b/types/types.go
@@ -2,10 +2,21 @@ package types
import (
"encoding/json"
+ "errors"
"github.com/google/uuid"
)
+// ErrRequiredNoValueProvided is a form validation error for required fields missing values
+var ErrRequiredNoValueProvided = errors.New("This field is required")
+
+// ValidationErrors is a mapping of form field IDs to the errors validating values submitted to those fields
+type ValidationErrors map[string]error
+
+func (v ValidationErrors) Any() bool {
+ return len(v) > 0
+}
+
// FormFieldType enum enumerates all possible form field types
//
//go:generate enumer -type FormFieldType -trimprefix FormFieldType -transform=snake -json
@@ -99,6 +110,21 @@ func (f FormFieldSortByOrder) Len() int { return len(f) }
func (f FormFieldSortByOrder) Swap(i, j int) { f[i], f[j] = f[j], f[i] }
func (f FormFieldSortByOrder) Less(i, j int) bool { return f[i].Order < f[j].Order }
+// Validate validates values submitted to a form field
+func (f FormField) Validate(value []string) (err error) {
+ if f.Required {
+ if len(value) == 0 {
+ return ErrRequiredNoValueProvided
+ }
+ for _, ffv := range value {
+ if ffv == "" {
+ return ErrRequiredNoValueProvided
+ }
+ }
+ }
+ return nil
+}
+
// MarshalJSON implements the json.Marshaler interface for FormFieldType
func (f FormField) MarshalJSON() ([]byte, error) {
id := uuid.Nil
diff --git a/ui/common.templ b/ui/common.templ
index 980e9ea..865e76c 100644
--- a/ui/common.templ
+++ b/ui/common.templ
@@ -240,8 +240,7 @@ templ App(pageTitle string) {
@head(pageTitle)
-
-
+
{ children... }
@@ -846,6 +845,10 @@ templ FormView(form frm.Form, isPreview bool) {
class="flex flex-col py-3"
}
>
+
switch field.Type {
case types.FormFieldTypeTextSingle:
@fieldLabel(field)
@@ -871,14 +874,16 @@ templ FormView(form frm.Form, isPreview bool) {
>
case types.FormFieldTypeSingleSelect, types.FormFieldTypeMultiSelect:
@selector.Selector(selector.SelectArgs{
- ID: field.ID.String(),
- Name: field.ID.String(),
- Label: field.Label,
- Required: field.Required,
- Placeholder: field.Placeholder,
- Multiple: field.Type == types.FormFieldTypeMultiSelect,
- Options: toSelectorOpts(field.Options, false),
- Hyperscript: fmt.Sprintf("on change trigger field_change(field_id: '%s', value: my.value)", field.ID.String()),
+ ID: field.ID.String(),
+ Name: field.ID.String(),
+ Label: field.Label,
+ Required: field.Required,
+ Placeholder: field.Placeholder,
+ Multiple: field.Type == types.FormFieldTypeMultiSelect,
+ Options: toSelectorOpts(field.Options, false),
+ SearchDisabled: true,
+ EditItems: false,
+ Hyperscript: fmt.Sprintf("on change trigger field_change(field_id: '%s', value: my.value)", field.ID.String()),
})
}
@@ -889,11 +894,12 @@ templ FormView(form frm.Form, isPreview bool) {
Type: "submit",
Classes: []string{"flex-grow", "justify-center", "uppercase"},
}, templ.Attributes{
- "type": "submit",
- "data-hx-post": formCollectorUrl[string](ctx, form),
- "data-hx-trigger": "click",
- "data-hx-swap": "none",
- "disabled": isPreview,
+ "type": "submit",
+ "data-hx-post": formCollectorUrl[string](ctx, form),
+ "data-hx-trigger": "click",
+ "data-hx-swap": "none",
+ "data-hx-target-400": "form",
+ "disabled": isPreview,
},
)
@@ -901,6 +907,10 @@ templ FormView(form frm.Form, isPreview bool) {
}
func toSelectorOpts(opts []types.Option, selectAll bool) (sopts []selector.Option) {
+ // TODO: Fix -- adding an empty option because the first select