mirror of
https://github.com/acaloiaro/frm
synced 2026-07-21 18:29:12 +00:00
250 lines
9.8 KiB
Text
250 lines
9.8 KiB
Text
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"context"
|
|
"github.com/acaloiaro/frm"
|
|
"github.com/acaloiaro/frm/ui/selector"
|
|
"github.com/acaloiaro/frm/types"
|
|
"encoding/json"
|
|
"slices"
|
|
)
|
|
|
|
var loadDependenciesOnce = templ.NewOnceHandle()
|
|
|
|
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>
|
|
}
|
|
|
|
// SafePath returns mountpoint-aware SafeURL paths for the given path
|
|
func SafePath(ctx context.Context, path string) templ.SafeURL {
|
|
return templ.SafeURL(frm.CollectorPath(ctx, path))
|
|
}
|
|
|
|
// head simply provides the <head> element
|
|
templ Head(pageTitle string) {
|
|
<head>
|
|
<title>{ pageTitle }</title>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
@loadDependenciesOnce.Once() {
|
|
<link href={ frm.CollectorPath(ctx, "/static/css/styles.css") } rel="stylesheet"/>
|
|
<link rel="stylesheet" href={ frm.CollectorPath(ctx, "/static/css/choices.min.css") } nonce={ templ.GetNonce(ctx) }/>
|
|
<script type="text/javascript" src={ frm.CollectorPath(ctx, "/static/js/htmx.js") } nonce={ templ.GetNonce(ctx) }></script>
|
|
<script type="text/javascript" src={ frm.CollectorPath(ctx, "/static/js/htmx-response-targets.js") } nonce={ templ.GetNonce(ctx) }></script>
|
|
<script type="text/javascript" src={ frm.CollectorPath(ctx, "/static/js/hyperscript.js") } nonce={ templ.GetNonce(ctx) }></script>
|
|
<script ytpe="text/javascript" src={ frm.CollectorPath(ctx, "/static/js/choices.min.js") } nonce={ templ.GetNonce(ctx) }></script>
|
|
<script type="text/javascript" src={ frm.CollectorPath(ctx, "/static/js/svg-loader.min.js") } nonce={ templ.GetNonce(ctx) } async></script>
|
|
<script type="text/javascript" src={ frm.CollectorPath(ctx, "/static/js/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);
|
|
});
|
|
}
|
|
})
|
|
|
|
// formValueChanged handles changes to user input in form fields
|
|
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 watcherFieldElement = document.getElementById(watchingField.id) // the actual element watching the field
|
|
// radio form elements such as "single choice" elements cannot get gotten by ID because they are
|
|
// radio button in a form group, all sharing a "name" attribute, rather than having one unique id
|
|
// like other input elements. Thus, when we cannot get a watching field by ID, we must be able to get it by
|
|
// name.
|
|
if (watcherFieldElement == null) {
|
|
radioFormElements = document.getElementsByName(watchingField.id)
|
|
if (radioFormElements.length > 0) {
|
|
watcherFieldElement = radioFormElements[0]
|
|
}
|
|
}
|
|
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;
|
|
case 'not':
|
|
match = watchingField.logic.trigger_values.some(val => newValue.toLowerCase() !== 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;
|
|
case "field_logic_trigger_require":
|
|
if (match) {
|
|
el.classList.remove("hidden")
|
|
watcherFieldElement.setAttribute("required", "")
|
|
} else {
|
|
el.classList.add("hidden")
|
|
watcherFieldElement.removeAttribute("required")
|
|
}
|
|
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>
|
|
<main>
|
|
{ children... }
|
|
</main>
|
|
@footer()
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// FormUrl returns form builder URLs for the given form and additional path arguments
|
|
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(F("%s%s", frm.BuilderPathForm(ctx, form.ID), p))
|
|
}
|
|
|
|
// 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 || f.Logic.TriggerValues == nil || len(f.Logic.TriggerValues) == 0 {
|
|
continue
|
|
}
|
|
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(),
|
|
Order: option.Order,
|
|
Selected: selected,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
templ ValidationErrors(errs types.ValidationErrors) {
|
|
if len(errs) > 0 {
|
|
for fieldID, err := range errs {
|
|
<div id={ fmt.Sprintf("errors-%s", fieldID) } data-hx-swap-oob="true" class="flex flex-col gap-3 py-3">
|
|
<p class="text-red-500">
|
|
{ err.Error() }
|
|
</p>
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// F formats strings; shorthand for fmt.Sprintf
|
|
func F(s string, args ...any) string {
|
|
return fmt.Sprintf(s, args...)
|
|
}
|