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