frm/ui/fields/fields.templ
2025-02-19 11:48:11 -07:00

258 lines
9 KiB
Text

package fields
import (
"encoding/json"
"fmt"
"sort"
"github.com/acaloiaro/frm"
"github.com/acaloiaro/frm/ui/selector"
"github.com/acaloiaro/frm/types"
"github.com/google/uuid"
)
// RequiredFieldIndicator visually indicates that a field is required
templ RequiredFieldIndicator() {
<span class="text-red-500 required-dot">*</span>
}
// FieldLabel is a HTML label for a field
templ FieldLabel(field types.FormField) {
<label for={ field.ID.String() } class="text-slate-700 text-xl pb-5">
{ field.Label }
if field.Required {
@RequiredFieldIndicator()
}
</label>
}
templ FieldTypeIcon(fieldType types.FormFieldType) {
<div class="p-1 rounded-md flex items-center justify-center bg-blue-100 text-blue-900 ml-2">
switch int(fieldType) {
case int(types.FormFieldTypeTextSingle), int(types.FormFieldTypeTextMultiple):
<!-- hericons: bards-3-bottom-left -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12"></path>
</svg>
case int(types.FormFieldTypeSingleSelect), int(types.FormFieldTypeMultiSelect):
<!-- heroicons: chevron-up-down -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15 12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"></path>
</svg>
case int(types.FormFieldTypeSingleChoice), int(types.FormFieldTypeSingleChoiceSpaced):
<!-- heroicons: outline star -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"></path>
</svg>
}
</div>
}
// View displays fields with the appropriate UI component
templ View(field types.FormField) {
switch field.Type {
case types.FormFieldTypeTextSingle:
@singleLineTextView(field)
case types.FormFieldTypeTextMultiple:
@multiLineTextView(field)
case types.FormFieldTypeSingleSelect, types.FormFieldTypeMultiSelect:
@selectView(field)
case types.FormFieldTypeSingleChoice:
@SingleChoice(field)
case types.FormFieldTypeSingleChoiceSpaced:
@SingleChoiceSpaced(field)
}
}
templ singleLineTextView(field types.FormField) {
@FieldLabel(field)
<input
id={ field.ID.String() }
name={ field.ID.String() }
placeholder={ field.Placeholder }
type="text"
autocomplete="off"
if field.Required {
required
}
_={ fmt.Sprintf("on keyup debounced at 250ms trigger field_change(field_id: '%s', value: my.value)", field.ID.String()) }
class="flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:border-2 focus:ring-opacity-100 px-4 py-2 text-base resize-y block rounded-xl bg-sky-50"
/>
<div hx-swap-oob="true" id={ fmt.Sprintf("errors-%s", field.ID.String()) }></div>
}
templ multiLineTextView(field types.FormField) {
@FieldLabel(field)
<textarea
id={ field.ID.String() }
name={ field.ID.String() }
class="flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full text-gray-700 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-500 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:border-transparent focus:ring-opacity-100 px-4 py-2 text-base resize-y block rounded-xl bg-sky-50"
placeholder={ field.Placeholder }
autocomplete="off"
if field.Required {
required
}
_={ fmt.Sprintf("on keyup debounced at 250ms trigger field_change(field_id: '%s', value: my.value)", field.ID.String()) }
rows="3"
></textarea>
<div
id={ fmt.Sprintf("errors-%s", field.ID.String()) }
class="text-red-400"
></div>
}
templ selectView(field types.FormField) {
@selector.Selector(selector.SelectArgs{
ID: field.ID.String(),
Name: field.ID.String(),
Label: field.Label,
LabelClass: "text-slate-700 text-xl py-1",
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()),
})
<div
id={ fmt.Sprintf("errors-%s", field.ID.String()) }
class="text-red-400"
></div>
}
// FormFieldTypeLabel is the UI label for FormFieldTypes, e.g. 'text_single' -> "Single line text"
templ FormFieldTypeLabel(fieldType types.FormFieldType) {
<div class="flex gap-3">
@FieldTypeIcon(types.FormFieldType(fieldType))
<label class="w-full cursor-pointer truncate">
// TODO: this int conversion is a janky artifact of keeping the public `frm` interface unpolluted by the
// `db` package. Something should be done to correct this.
// FIELD_TYPES: field types may be added/modified/removed below
switch int(fieldType) {
case int(types.FormFieldTypeTextSingle):
Single-line text
case int(types.FormFieldTypeTextMultiple):
Multi-line text
case int(types.FormFieldTypeSingleSelect):
Single select
case int(types.FormFieldTypeMultiSelect):
Multi select
case int(types.FormFieldTypeSingleChoice):
Single Choice
case int(types.FormFieldTypeSingleChoiceSpaced):
Single Choice (spaced)
}
</label>
</div>
}
// ToSelectorOpts converts a slices of types.Option to a slice of selector.Option
func ToSelectorOpts(opts []types.Option, selectAll bool) (sopts []selector.Option) {
for _, opt := range opts {
if selectAll {
opt.Selected = true
}
sopts = append(sopts, (selector.Option)(opt))
}
return
}
// ToSelectorOptsStr converts a slices of types.Option to a slice of selector.Option
func ToSelectorOptsStr(opts []string, selectAll bool) (sopts []selector.Option) {
// TODO: Fix -- adding an empty option because the first select <option> is selected by default, for some reason
sopts = append(sopts, (selector.Option)(selector.Option{
Value: "",
}))
for _, opt := range opts {
sopts = append(sopts, selector.Option{
Label: opt,
Value: opt,
Selected: selectAll,
})
}
return
}
// FieldsAsSelector returns all of a form's fields as selector.Options to be used in a selector.Selector dropdown
// fieldID is the ID of the field for which the options are being rendered
func FieldsAsSelectorOptions(form frm.Form, fieldID uuid.UUID) (options []selector.Option) {
for _, field := range form.Fields {
// fields should not show themselves as options
if field.ID == fieldID {
continue
}
selected := false
for _, f := range form.Fields {
if f.ID == fieldID && f.Logic != nil && f.Logic.TargetFieldID == field.ID {
selected = true
}
}
options = append(options, selector.Option{
ID: field.ID,
Label: field.Label,
Value: field.ID.String(),
Selected: selected,
})
}
return
}
// fieldTypeToDefaultValuesJSON converts field types to their devault values.
// This function takes a fieldType and converts it to the default values for that field type,
// and encodes it as a JSON string.
func fieldTypeToDefaultValuesJSON(fieldType types.FormFieldType) string {
label := ""
placeholder := ""
switch int(fieldType) {
case int(types.FormFieldTypeTextSingle), int(types.FormFieldTypeTextMultiple):
label = "New Text Field"
placeholder = "Enter some text"
case int(types.FormFieldTypeSingleSelect):
label = "New Single Select Field"
placeholder = "Choose An Item"
case int(types.FormFieldTypeMultiSelect):
label = "New Multi Select Field"
placeholder = "Choose Items"
}
field := types.FormField{
Label: label,
Placeholder: placeholder,
Required: false,
Hidden: false,
Type: fieldType,
}
json, err := json.Marshal(field)
if err != nil {
return ""
}
return string(json)
}
// toFrmFields converts frm.FormFields (map[string]frm.FormField) --> []frm.FormField
func toFrmFields(fields types.FormFields) (ffields []types.FormField) {
for _, field := range fields {
ffields = append(ffields, (types.FormField)(field))
}
return
}
// Sort form fields by Order
func SortFields(fields types.FormFields) (sorted []types.FormField) {
sorted = toFrmFields(fields)
sort.Sort(types.FormFieldSortByOrder(sorted))
return
}
// FieldName generates the HTML form field name for form fileds
func FieldName(field types.FormField, group, name string) string {
if group == "" {
return fmt.Sprintf("[%s]%s", field.ID.String(), name)
}
return fmt.Sprintf("[%s][%s]%s", field.ID.String(), group, name)
}