frm/ui/common.templ
2025-01-04 09:02:33 -07:00

918 lines
36 KiB
Text

package ui
import (
"fmt"
"strings"
"context"
"github.com/acaloiaro/frm"
"github.com/acaloiaro/frm/ui/selector"
"sort"
"github.com/acaloiaro/frm/types"
"encoding/json"
"github.com/google/uuid"
"slices"
)
var loadDependenciesOnce = templ.NewOnceHandle()
// General settings
const (
ApplicationName = "frm"
)
// Application DOM events
const (
// FieldsFormEvent is the event triggered when any of a form's fields have been updated in the UI. When triggered,
// the fields form pushes updates from the UI to the draft form on the backend.
FieldsFormUpdateEvent = "fields-form-updated"
// FormSettingsEvent is the event triggered when any of a form's settings have been updated in the UI. When triggered,
// the fields form pushes updates from the UI to the draft form on the backend.
FormSettingsUpdateEvent = "form-settings-updated"
// LogicConfiguratorTargetFieldSelected is the event triggered when a form field is selected as a the target for field logic
LogicConfiguratorTargetFieldSelected = "logic-configurator-target-field-selected"
)
// Field groups
const (
FieldGroupLogic = "logic"
)
// Logic field group form field names
const (
// FieldLogicChosenFieldID is the name of a HTML form field. The value of the form field with this name represents the field that was chosen as the target for field-specific logic.
//
// TODO: provide an example
FieldLogicTargetFieldID = "target_field_id"
// FieldLogicChosenField is the name of a HTML form field. The value of the form field represents the boolean comparator to be used to evaluate the chosen field against its field value.
//
// TODO: provide an example
FieldLogicComparator = "comparator"
// FieldLogicChosenFieldValue is the name of a HTML Form field. The value of the form field represents the value that the target field's value is compared against when deciding whether or not to display a field in the UI.
//
// TODO: provide an example
FieldLogicTargetFieldValue = "target_field_value"
)
templ HeroIcon(style string, name string) {
<svg data-src={ fmt.Sprintf("https://unpkg.com/heroicons/20/%s/%s.svg", style, name) } class="h-5 w-5"></svg>
}
type buttonArgs struct {
Type string // button type, e.g. 'button' or 'submit'
Label string // the label to show
Classes []string // additional css classes to apply to the button
}
templ button(args buttonArgs, attrs templ.Attributes) {
<button { attrs... } class={ fmt.Sprintf("focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:cursor-not-allowed aria-disabled:opacity-75 flex-shrink-0 font-medium rounded-md text-sm gap-x-1.5 shadow-sm text-white dark:text-gray-900 bg-primary-500 hover:bg-primary-600 disabled:bg-primary-500 aria-disabled:bg-primary-500 dark:bg-primary-400 dark:hover:bg-primary-500 dark:disabled:bg-primary-400 dark:aria-disabled:bg-primary-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:focus-visible:outline-primary-400 inline-flex items-center px-8 md:px-4 py-2 %s", strings.Join(args.Classes, " ")) }>
{ children... }
{ args.Label }
</button>
}
templ mutedButton(args buttonArgs, attrs templ.Attributes) {
<div { attrs... } class={ fmt.Sprintf("btn btn-sm text-black text-md bg-gray-50 focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:cursor-not-allowed aria-disabled:opacity-75 flex-shrink-0 font-medium rounded-md gap-x-1.5 shadow-sm dark:text-gray-900 bg-gray-100 hover:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:focus-visible:outline-primary-400 inline-flex items-center px-8 md:px-4 py-2 %s", strings.Join(args.Classes, " ")) }>
{ children... }
{ args.Label }
</div>
}
// FormBuilderNavTitle renders the title of the form in the nav bar. This is a separate template so that the title can be updated when settings change, without updating, and losing the state of, the rest of the nav bar
templ FormBuilderNavTitle(form frm.Form) {
<div id="form-builder-nav-title" data-hx-swap-oob="true" class="flex-grow flex justify-center">
<h3 tabindex="0" class="hover:bg-gray-100 dark:hover:bg-gray-800 rounded px-2 cursor-pointer relative font-medium py-1 text-md w-1/3 text-gray-500 truncate form-editor-title" style="height: auto;" id="form-editor-title">
{ form.Name }
</h3>
</div>
}
// FormBuilderNav is the top-of-the-page navigation bar
templ FormBuilderNav(form frm.Form) {
<div id="form-builder-nav" data-hx-swap-oob="true" class="w-full border-b p-2 flex gap-x-2 items-center bg-white">
<div id="form-editor-navbar-tabs">
<div role="tablist" aria-orientation="horizontal" class="tabs tabs-boxed bg-gray-50 dark:bg-gray-800 rounded-lg p-1 h-auto grid grid-cols-2 items-center gap-x-1.5 px-2">
@mutedButton(buttonArgs{Label: "Build", Classes: []string{"cursor-pointer tab-active [--fallback-p:white] [--fallback-pc:black] place-self-center"}}, templ.Attributes{
"role": "tab",
"tabindex": 0,
"_": "on click take .tab-active from .tab-active then add .hidden to .active-section then take .active-section for #form-fields then remove .hidden from #form-fields",
})
@mutedButton(buttonArgs{Label: "Settings", Classes: []string{"cursor-pointer [--fallback-p:white] [--fallback-pc:black] place-self-center"}}, templ.Attributes{
"role": "tab",
"tabindex": 2,
"_": "on click take .tab-active from .tab-active then add .hidden to .active-section take .active-section for #settings-main then remove .hidden from #settings-main",
})
</div>
</div>
@FormBuilderNavTitle(form)
<div class="flex items-stretch gap-x-2">
<div class="inline-flex items-center relative">
<a href="#" class="text-sm p-2 hover:bg-gray-100 cursor-pointer rounded-lg text-gray-500 hover:text-gray-800 cursor-pointer"><span class="iconify i-heroicons:question-mark-circle w-5 h-5" aria-hidden="true"></span></a><!---->
</div>
<div class="relative inline-flex">
<button
type="button"
class="focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:cursor-not-allowed aria-disabled:opacity-75 flex-shrink-0 font-medium rounded-md text-sm gap-x-1.5 shadow-sm text-white dark:text-gray-900 bg-primary-500 hover:bg-primary-600 disabled:bg-primary-500 aria-disabled:bg-primary-500 dark:bg-primary-400 dark:hover:bg-primary-500 dark:disabled:bg-primary-400 dark:aria-disabled:bg-primary-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:focus-visible:outline-primary-400 inline-flex items-center px-8 md:px-4 py-2"
data-hx-put={ formUrl[string](ctx, form, "/publish") }
data-hx-trigger="click"
data-hx-swap="none"
>
<!---->
<svg class="w-4 h-4 text-white inline mr-1 -mt-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17 21V13H7V21M7 3V8H15M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H16L21 8V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg> Save Form <!---->
</button><!---->
</div>
</div>
</div>
}
// SafePath returns mountpoint-aware SafeURL paths for the given path
func SafePath(ctx context.Context, path string) templ.SafeURL {
return templ.SafeURL(frm.URLPath(ctx, path))
}
// head simply provides the <head> element
templ head(pageTitle string) {
<head>
<title>{ ApplicationName } :: { pageTitle }</title>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
@loadDependenciesOnce.Once() {
<link href={ frm.URLPath(ctx, "/static/css/styles.css") } rel="stylesheet"/>
<link rel="stylesheet" href={ frm.URLPath(ctx, "/static/css/choices.min.css") } nonce={ templ.GetNonce(ctx) }/>
<script type="text/javascript" src={ frm.URLPath(ctx, "/static/js/htmx.js") } nonce={ templ.GetNonce(ctx) }></script>
<script type="text/javascript" src={ frm.URLPath(ctx, "/static/js/htmx-response-targets.js") } nonce={ templ.GetNonce(ctx) }></script>
<script type="text/javascript" src={ frm.URLPath(ctx, "/static/js/hyperscript.js") } nonce={ templ.GetNonce(ctx) }></script>
<script ytpe="text/javascript" src={ frm.URLPath(ctx, "/static/js/choices.min.js") } nonce={ templ.GetNonce(ctx) }></script>
<script type="text/javascript" src="https://unpkg.com/external-svg-loader@latest/svg-loader.min.js" nonce={ templ.GetNonce(ctx) } async></script>
<script type="text/javascript" src="https://unpkg.com/sortablejs@latest/Sortable.min.js" nonce={ templ.GetNonce(ctx) }></script>
<script type="text/javascript">
htmx.onLoad(function(content) {
var sortables = content.querySelectorAll(".sortable");
for (var i = 0; i < sortables.length; i++) {
var sortable = sortables[i];
var sortableInstance = new Sortable(sortable, {
animation: 150,
draggable: ".sortme",
onMove: function (evt) {
return evt.related.className.indexOf('htmx-indicator') === -1;
},
onEnd: function (evt) {
this.option("disabled", true);
}
});
// Re-enable sorting on the `htmx:afterSwap` event
sortable.addEventListener("htmx:afterSwap", function() {
sortableInstance.option("disabled", false);
});
}
})
function formValueChanged(fieldID, newValue) {
var formMetadata = JSON.parse(document.getElementById('form-metadata').getAttribute("data-data"));
if (formMetadata == null) {
return
}
// collect the fields that have logic monitoring the changed field
var watchingFields = Object.values(formMetadata.form.fields).filter(function(field) {
return field.logic != null && fieldID === field.logic.target_field_id
});
// no fields watch the one that changed
if (watchingFields.length == 0) {
return
}
var fieldElement = document.getElementById(fieldID)
for (i in watchingFields) {
let watchingField = watchingFields[i]
let match = false
var watcherFieldContainerID = `field-container-${watchingField.id}` // the DOM element that contains the watching field
logic = watchingField.logic
// check whether the new value is coming from a Choices.js field, in which case the new value
// is the array of chosen values, joined by commas, otherwise newValue is used as it was passed in
if (fieldElement != null && fieldElement._choices != null) {
// Choics.getValue() returns scalar for single selects and array for multi. Use Array.of
// to treat everything it returns as an array
newValue = Array.of(fieldElement._choices.getValue(true)).join(',')
}
// find if _any_ trigger values match the new value
switch (logic.field_comparator) {
case 'equal':
match = watchingField.logic.trigger_values.every(val => newValue.localeCompare(val, 'en', {sensitivity: "base"}) == 0)
break;
case 'contains':
match = watchingField.logic.trigger_values.some(val => newValue.toLowerCase().includes(val.toLowerCase()))
break;
}
// Most actions are likely to be performed upon the containing element, such as show/hide/require.
// This may of course change or be expanded, but for now, only the watcher field container is relevant
// to applying actions.
let el = document.getElementById(watcherFieldContainerID)
let actions = watchingField.logic.actions
// we have a match, execute the action
for (i in actions) {
switch (actions[i]) {
case "field_logic_trigger_show":
if (match) {
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
break;
}
}
}
}
</script>
}
</head>
}
templ footer() {
// <footer class="relative text-gray text-xs bottom-0 px-6 py-3 bg-base-200 print:hidden"></footer>
}
// App is the primary app with all chrome
templ App(pageTitle string) {
<!DOCTYPE html>
<html lang="eng">
@head(pageTitle)
<body class="">
<div id="errors"></div>
<main hx-ext="response-targets">
{ children... }
</main>
@footer()
</body>
</html>
}
// Builder is the primary form builder UI, surrounded by the app chrome
templ Builder(form frm.Form) {
@App("Form builder") {
<section id="app-container">
@FormBuilderNav(form)
<section id="builder-main" class="flex w-full">
<!-- Left column -->
@builderColumnLeft(form)
<!-- Middle Column -->
@FormPreview(form)
<!-- Right Column -->
@builderColumnRight(form)
</section>
</section>
}
}
// FormSettings is the UI for configuring form-level settings.
templ FormSettings(form frm.Form) {
<div id="settings-main" class="hidden">
<form
id="settings-form"
data-hx-put={ formUrl[string](ctx, form, "/settings") }
data-hx-trigger={ FormSettingsUpdateEvent }
data-hx-swap-oob="true"
>
<div class="pt-3">
<label for="form-name-field" class="pr-1">
Form name
</label>
@requiredFieldIndicator()
</div>
<input
id="form-name-field"
name="name"
type="text"
class="w-full rounded-md"
value={ form.Name }
placeholder="Enter a good name for your form"
autocomplete="off"
_={ fmt.Sprintf("on keyup debounced at 600ms trigger '%s'", FormSettingsUpdateEvent) }
/>
</form>
</div>
}
// builderColumnLeft is the left-hand panel of the form builder UI
templ builderColumnLeft(form frm.Form) {
<section id="builder-main-left-col" class="flex flex-col gap-3 w-1/4 min-w-max h-full p-4 text-gray-800 rounded-md">
@FormFields(form)
@FormSettings(form)
</section>
}
// FormPreview previews a frm.Form
templ FormPreview(form frm.Form) {
<section id="builder-main-middle-col" class="w-full h-screen overflow-y-auto overscroll-auto p-4 bg-gray-50 border-y-1">
<div class="mockup-browser border-base-300 border bg-white">
<div class="mockup-browser-toolbar">
<div class="input border-base-300 border">https://your-form-domain.com/form</div>
</div>
<div class="h-full border-base-300 flex border-t px-4 py-8">
@FormView(form, true)
</div>
</div>
</section>
}
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):
@HeroIcon("solid", "bars-3-bottom-left")
case int(types.FormFieldTypeSingleSelect), int(types.FormFieldTypeMultiSelect):
@HeroIcon("solid", "chevron-up-down")
}
</div>
}
// FormFields lists a form's fields as a sortable list, that when re-sorted, updates the fields' sort order in the form
templ FormFields(form frm.Form) {
<div id="form-fields" class="active-section">
@FormFieldsForm(form)
</div>
}
templ FormFieldsForm(form frm.Form) {
<div id="form-fields-form" data-hx-swap-oob="true" class="flex flex-col gap-3 w-full">
@button(buttonArgs{
Label: "Add field",
Classes: []string{"flex-grow", "justify-center", "uppercase"},
}, templ.Attributes{
"_": "on click toggle .hidden on .active-configurator then take .active-configurator from .active-configurator for #configure-add-field then remove .hidden from #configure-add-field",
}) {
@HeroIcon("solid", "plus")
}
<div class="w-full border-b pb-2"></div>
<form
data-hx-put={ formUrl[string](ctx, form, "/fields/order") }
data-hx-trigger="end"
data-hx-swap="outerHTML"
data-hx-target="#form-fields-form"
data-hx-indidcator="#ind"
>
<div class="flex flex-col gap-1 sortable">
for _, field := range sortFields(form.Fields) {
<div class="mx-auto w-full border-gray-300 transition-colors bg-gray-50 hover:bg-gray-100 rounded-lg sortme max-w-84 field-row">
<input name="order" type="hidden" value={ field.ID.String() }/>
<div class="group flex items-center gap-x-0.5 py-1.5 pr-1">
<!-- field item -->
<a
href="#"
class="w-full"
_={ fmt.Sprintf("on click add .hidden to .active-configurator then take .active-configurator from .active-configurator for #configure-%s then remove .hidden from #configure-%s", field.ID.String(), field.ID.String()) }
>
<div class="flex flex-col cursor-pointer">
<div tabindex="0" class="dark:hover:bg-gray-800 rounded px-2 relative text-gray-700 max-w-72 min-h-6" style="height: auto;">
<p class="w-full cursor-pointer truncate">{ field.Label }</p>
</div>
</div>
</a>
if field.Required {
<div class="relative inline-flex">
<button class="hidden rounded p-0.5 transition-colors hover:bg-nt-blue-lighter items-center px-1 justify-center md:flex text-red-500">
<div class="h-6 text-center text-2xl font-bold text-inherit -mt-0.5">* </div>
</button>
</div>
}
<div class="cursor-move">
@HeroIcon("solid", "bars-3")
</div>
</div>
</div>
}
</div>
</form>
</div>
}
templ requiredFieldIndicator() {
<span class="text-red-500 required-dot">*</span>
}
// 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)
}
// builerColumnRight is the right-hand panel of the form build UI, used for adding and configuring fields
templ builderColumnRight(form frm.Form) {
<section id="builder-main-right-col" class="w-1/4 h-full p-4 text-gray-800 rounded-md">
<div id="configure-add-field" class="hidden">
<div class="h-12 border-b text-lg text-center uppercase">Add field</div>
for i, fieldType := range types.FormFieldTypeValues() {
<div class="group flex items-center my-1.5 pr-1">
<a href="#" class="w-full">
<div class="flex flex-col">
<div
tabindex={ fmt.Sprint(i) }
class="hover:bg-gray-50 dark:hover:bg-gray-800 rounded cursor-pointer relative truncate text-gray-700 min-w-16 min-h-6"
style="height: auto;"
data-hx-post={ formUrl[string](ctx, form, "/fields") }
data-hx-trigger="click"
data-hx-vals={ fmt.Sprintf(`{"field_type": "%s"}`, fieldType) }
data-hx-swap="none"
>
@formFieldTypeLabel(types.FormFieldType(fieldType))
</div>
</div>
</a>
</div>
}
</div>
@FormFieldConfigurator(form)
</section>
}
func formUrl[T string | templ.SafeURL](ctx context.Context, form frm.Form, path ...string) T {
p := ""
if len(path) == 1 {
p = path[0]
}
return T(frm.URLPath(ctx, f("/forms/%d%s", form.ID, p)))
}
templ FormFieldConfigurator(form frm.Form) {
<form
id="fields-form"
data-hx-put={ formUrl[string](ctx, form, "/fields") }
data-hx-trigger={ FieldsFormUpdateEvent }
data-hx-swap="none"
data-hx-swap-oob="true"
>
for _, field := range sortFields(form.Fields) {
<div id={ fmt.Sprintf("configure-%s", field.ID.String()) } class="hidden">
<div class="mx-auto w-full border-gray-300 transition-colors pb-3">
@formFieldTypeLabel(types.FormFieldType(field.Type))
</div>
<div id={ fmt.Sprintf("configurator-tabs-%s", field.ID.String()) } class="border-b pb-4">
<div role="tablist" aria-orientation="horizontal" class="tabs tabs-boxed bg-gray-50 dark:bg-gray-800 rounded-lg h-auto flex gap-2">
@mutedButton(buttonArgs{Label: "Settings", Classes: []string{"cursor-pointer tab-active [--fallback-p:white] [--fallback-pc:black] place-self-center w-1/2"}}, templ.Attributes{
"role": "tab",
"tabindex": 1,
"_": fmt.Sprintf("on click take .tab-active from <#configurator-tabs-%s .tab-active/> then add .hidden to #field-%s-logic then take .active-configurator-section from #field-%s-logic for #%s then remove .hidden from #%s", field.ID.String(), field.ID.String(), field.ID.String(), fmt.Sprintf("field-%s-settings", field.ID.String()), fmt.Sprintf("field-%s-settings", field.ID.String())),
})
@mutedButton(buttonArgs{Label: "Logic", Classes: []string{"cursor-pointer [--fallback-p:white] [--fallback-pc:black] place-self-center w-1/2"}}, templ.Attributes{
"role": "tab",
"tabindex": 2,
"_": fmt.Sprintf("on click take .tab-active from <#configurator-tabs-%s .tab-active/> then add .hidden to #field-%s-settings then take .active-configurator-section from #field-%s-settings for #%s then remove .hidden from #%s", field.ID.String(), field.ID.String(), field.ID.String(), fmt.Sprintf("field-%s-logic", field.ID.String()), fmt.Sprintf("field-%s-logic", field.ID.String())),
})
</div>
</div>
<!-- Form fields settings configurations -->
@fieldSettingsConfiguration(form, field)
<!-- Form fields logic configurations -->
@fieldLogicConfiguration(form, field)
</div>
}
</form>
}
templ fieldSettingsConfiguration(form frm.Form, field types.FormField) {
<div id={ fmt.Sprintf("field-%s-settings", field.ID.String()) } class="active-configurator-section">
<input name={ fieldName(field, "", "required") } type="hidden" value={ fmt.Sprint(field.Required) }/>
<input name={ fieldName(field, "", "hidden") } type="hidden" value={ fmt.Sprint(field.Hidden) }/>
<input name={ fieldName(field, "", "field_type") } type="hidden" value={ fmt.Sprint(field.Type) }/>
<div class="pt-3">
<label for={ fieldName(field, "", "label") } class="pr-1">
Field label
</label>
@requiredFieldIndicator()
</div>
<input
id={ fieldName(field, "", "label") }
name={ fieldName(field, "", "label") }
type="text"
class="w-full rounded-md"
value={ field.Label }
autocomplete="off"
_={ fmt.Sprintf("on keyup debounced at 600ms trigger '%s'", FieldsFormUpdateEvent) }
/>
<div class="pt-3">
<label for={ fieldName(field, "", "placeholder") } class="pr-1">
Placeholder
</label>
</div>
<input
id={ fieldName(field, "", "placeholder") }
name={ fieldName(field, "", "placeholder") }
type="text"
class="w-full rounded-md"
value={ field.Placeholder }
autocomplete="off"
_={ fmt.Sprintf("on keyup debounced at 600ms trigger '%s'", FieldsFormUpdateEvent) }
/>
if field.Type == types.FormFieldTypeSingleSelect || field.Type == types.FormFieldTypeMultiSelect {
<div class="pt-3">
<label for={ fieldName(field, "", "options") } class="pr-1">
Options
</label>
</div>
@selector.Selector(selector.SelectArgs{
ID: fieldName(field, "", "options"),
Name: fieldName(field, "", "options"),
Placeholder: "Add, remove, or create new options",
Multiple: true,
EditItems: true,
Options: toSelectorOpts(field.Options, true),
SelectionChangeEvent: FieldsFormUpdateEvent,
})
}
<div class="pt-3">
<label for={ fieldName(field, "", "required") } class="pr-1">
Required
</label>
</div>
<input
id={ fieldName(field, "", "required") }
name={ fieldName(field, "", "required") }
type="checkbox"
class="checkbox checkbox-primary"
if field.Required {
checked
}
_={ fmt.Sprintf(`
on click
if my.checked
set <input[name='%s']/>'s checked to false
end
then trigger '%s'`,
fieldName(field, "","hidden"), FieldsFormUpdateEvent) }
/>
<div class="pt-3">
<label for={ fieldName(field, "", "hidden") } class="pr-1">
Hidden
</label>
</div>
<input
id={ fieldName(field, "", "hidden") }
name={ fieldName(field, "", "hidden") }
type="checkbox"
class="checkbox checkbox-primary"
if field.Hidden {
checked
}
_={ fmt.Sprintf(`
on click
if my.checked
set <input[name='%s']/>'s checked to false
end
then trigger '%s'`,
fieldName(field, "","required"), FieldsFormUpdateEvent) }
/>
<div class="py-3 divide-y">
<label for="delete-field" class="pr-1">
Danger zone
</label>
</div>
@button(buttonArgs{Type: "button", Label: "Delete field"}, templ.Attributes{
"data-hx-delete": formUrl[string](ctx, form, fmt.Sprintf("/fields/%s", field.ID)),
"data-hx-trigger": "click",
"data-hx-confirm": "Are you sure?",
})
</div>
}
templ fieldLogicConfiguration(form frm.Form, field types.FormField) {
<div id={ fmt.Sprintf("field-%s-logic", field.ID.String()) } class="flex flex-col gap-5 hidden">
<div>
<div
data-hx-get={ frm.URLPath(ctx, fmt.Sprintf("%d/logic_configurator/%s/step3", form.ID, field.ID.String())) }
data-hx-trigger={ LogicConfiguratorTargetFieldSelected }
data-hx-swap="innerHTML"
data-hx-target={ fmt.Sprintf("#logic-field-value-chooser-%s", field.ID.String()) }
data-hx-on:htmx:config-request="event.detail.parameters['id'] = event.detail.triggeringEvent.detail.value"
>
@selector.Selector(selector.SelectArgs{
ID: fmt.Sprintf("field-%s-logic-config-field-chooser", field.ID.String()),
Name: fieldName(field, FieldGroupLogic, FieldLogicTargetFieldID),
Placeholder: "Choose a field",
Options: fieldsAsSelectorOptions(form, field.ID),
SearchDisabled: true,
SelectionChangeEvent: LogicConfiguratorTargetFieldSelected,
})
</div>
</div>
<div>
@selector.Selector(selector.SelectArgs{
ID: fmt.Sprintf("field-%s-logic-config-condition-chooser", field.ID.String()),
Name: fieldName(field, FieldGroupLogic, FieldLogicComparator),
Placeholder: "Choose a condition",
Options: comparatorOptionsFor(field),
SearchDisabled: true,
SelectionChangeEvent: FieldsFormUpdateEvent,
})
</div>
<div>
<div
id={ fmt.Sprintf("logic-field-value-chooser-%s", field.ID.String()) }
>
if field.Logic != nil && field.Logic.TargetFieldID != uuid.Nil {
@LogicConfiguratorStepThree(form, field, form.Fields[field.Logic.TargetFieldID.String()])
}
</div>
</div>
<div>
<p class="pb-3">Choose an action</p>
<input
id={ fmt.Sprintf("%s-logic-action", field.ID.String()) }
type="checkbox"
class="checkbox"
name={ fieldName(field, FieldGroupLogic, types.FieldLogicTriggerShow.String()) }
if field.Logic != nil && field.Logic.TriggerActions.Contains(types.FieldLogicTriggerShow) {
checked
}
_={ fmt.Sprintf(`
on click
trigger '%s'`,
FieldsFormUpdateEvent) }
/>
<label for={ fmt.Sprintf("%s-logic-action", field.ID.String()) }>Show this field</label>
</div>
</div>
}
// comparatorOptionsFor returns the comparators available for a logic field given its Type
func comparatorOptionsFor(field types.FormField) (options selector.FieldOptions) {
switch field.Type {
default:
options = selector.FieldOptions{
selector.Option{
Value: fmt.Sprint(types.FieldLogicComparatorEqual),
Label: "Equal to =",
Selected: field.Logic != nil && field.Logic.TriggerComparator == types.FieldLogicComparatorEqual,
},
selector.Option{
Value: fmt.Sprint(types.FieldLogicComparatorContains),
Label: "Contains",
Selected: field.Logic != nil && field.Logic.TriggerComparator == types.FieldLogicComparatorContains,
},
}
}
return
}
// LogicConfiguratorStepThree returns HTML input elments appropriate for choosing values for `targetField` in the logic configurator.
//
// field: the field being configured
// targetField: the target field chosen as the logic target
templ LogicConfiguratorStepThree(form frm.Form, field types.FormField, targetField types.FormField) {
switch targetField.Type {
case types.FormFieldTypeMultiSelect, types.FormFieldTypeSingleSelect:
@selector.Selector(selector.SelectArgs{
ID: fmt.Sprintf("%s-logic-chosen-field-value", field.ID.String()),
Label: "",
Name: fieldName(field, FieldGroupLogic, FieldLogicTargetFieldValue),
Options: fieldOptionsAsSelectorOptions(form, targetField),
Placeholder: "Choose a value",
SelectionChangeEvent: FieldsFormUpdateEvent,
})
case types.FormFieldTypeTextSingle, types.FormFieldTypeTextMultiple:
<input
id={ fmt.Sprintf("%s-logic-chosen-field-value", field.ID.String()) }
name={ fieldName(field, FieldGroupLogic, FieldLogicTargetFieldValue) }
type="text"
class="bg-gray-50"
placeholder="Enter a value"
if field.Logic != nil && field.Logic.TriggerValues[0] != "" {
value={ field.Logic.TriggerValues[0] }
}
_={ fmt.Sprintf("on keyup debounced at 600ms trigger '%s'", FieldsFormUpdateEvent) }
/>
}
}
// 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.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
}
// fieldOptionsAsSelectorOptions returns all of a field's FieldOptions as selector.Options
func fieldOptionsAsSelectorOptions(form frm.Form, field types.FormField) (options []selector.Option) {
for _, option := range field.Options {
selected := false
for _, f := range form.Fields {
if f.Logic != nil && slices.Contains(f.Logic.TriggerValues, option.ID.String()) {
selected = true
}
}
options = append(options, selector.Option{
ID: option.ID,
Label: option.Label,
Value: option.ID.String(),
Selected: selected,
})
}
return
}
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)
}
// 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.
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
default:
<label class="w-full cursor-pointer truncate">I unno</label>
}
</label>
</div>
}
templ fieldLabel(field types.FormField) {
<label for={ field.ID.String() }>
{ field.Label }
if field.Required {
@requiredFieldIndicator()
}
</label>
}
// ViewerMetdata contains data needed by the viewer component. It is rendered to JSON and accessed via Javascript.
type ViewerMetadata struct {
Form frm.Form `json:"form"`
}
func (v ViewerMetadata) JSON() string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
// FormView renders forms
templ FormView(form frm.Form, isPreview bool) {
<div id="form-viewer" class="flex flex-col w-full justify-top" data-hx-swap-oob="true">
<div
id="form-metadata"
data-data={ ViewerMetadata{Form: form}.JSON() }
></div>
<h1 class="hover:bg-gray-50 dark:hover:bg-gray-800 rounded cursor-pointer relative mb-2 font-black text-5xl">
{ form.Name }
</h1>
<form
action={ templ.SafeURL(fmt.Sprintf("/%d", form.ID)) }
_="on field_change(field_id, value) formValueChanged(field_id, value)"
>
for _, field := range sortFields(form.Fields) {
<div
id={ fmt.Sprintf("field-container-%s", field.ID.String()) }
if field.Hidden {
class="flex flex-col py-3 hidden"
} else {
class="flex flex-col py-3"
}
>
switch field.Type {
case types.FormFieldTypeTextSingle:
@fieldLabel(field)
<input
id={ field.ID.String() }
name={ field.ID.String() }
placeholder={ field.Placeholder }
type="text"
autocomplete="off"
_={ fmt.Sprintf("on keyup debounced at 250ms trigger field_change(field_id: '%s', value: my.value)", field.ID.String()) }
/>
case types.FormFieldTypeTextMultiple:
@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 bg-white 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 rounded-lg px-4 py-2 text-base resize-y block"
name="98e021e4-68e0-4273-8576-de89b4459e62"
placeholder={ field.Placeholder }
autocomplete="off"
style="--tw-ring-color: #3B82F6;"
_={ fmt.Sprintf("on keyup debounced at 250ms trigger field_change(field_id: '%s', value: my.value)", field.ID.String()) }
></textarea>
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()),
})
}
</div>
}
<div class="py-3"></div>
@button(buttonArgs{
Label: "submit",
Type: "submit",
Classes: []string{"flex-grow", "justify-center", "uppercase"},
}, templ.Attributes{
"type": "submit",
"data-hx-post": fmt.Sprintf("/%d", form.ID),
"data-hx-trigger": "click",
"data-hx-swap": "none",
"disabled": true,
},
)
</form>
</div>
}
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
}
// 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
}
// f is shorthand for fmt.Sprintf, for readability
func f(s string, args ...any) string {
return fmt.Sprintf(s, args...)
}