mirror of
https://github.com/acaloiaro/frm
synced 2026-07-21 18:29:12 +00:00
918 lines
36 KiB
Text
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...)
|
|
}
|