2024-11-05 16:18:04 +00:00
|
|
|
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()
|
|
|
|
|
|
2025-03-26 21:29:25 +00:00
|
|
|
type ToastType string
|
|
|
|
|
type ToastPosition string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
ToastPositionTop = "top"
|
|
|
|
|
ToastPositionBottom = "bottom"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
ToastTypeSuccess = "success"
|
|
|
|
|
ToastTypeWarning = "warning"
|
|
|
|
|
ToastTypeError = "error"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type ToastArgs struct {
|
|
|
|
|
Message string
|
|
|
|
|
Position string
|
|
|
|
|
Type ToastType
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Toast shows a toast message on screen
|
|
|
|
|
templ Toast(arg ToastArgs) {
|
|
|
|
|
<div
|
2025-03-27 19:14:25 +00:00
|
|
|
class="toast toast-top toast-right pt-12 z-50 opacity-0"
|
2025-03-26 21:29:25 +00:00
|
|
|
_="on intersection(intersecting) transition my opacity from 0 to 1 over 500 milliseconds
|
|
|
|
|
then wait 3s
|
|
|
|
|
then transition my opacity from 1 to 0 over 500 milliseconds"
|
|
|
|
|
>
|
|
|
|
|
switch arg.Type {
|
|
|
|
|
case ToastTypeSuccess:
|
|
|
|
|
<div
|
|
|
|
|
class="card rounded-md bg-base-100 shadow-lg p-2 text-white bg-primary-400"
|
|
|
|
|
_="on click add .hidden to #messages"
|
|
|
|
|
>
|
|
|
|
|
<span>{ arg.Message }</span>
|
|
|
|
|
</div>
|
|
|
|
|
case ToastTypeWarning:
|
|
|
|
|
<div
|
|
|
|
|
class="card rounded-md bg-base-100 shadow-lg p-2 text-white bg-orange-400"
|
|
|
|
|
_="on click add .hidden to #messages"
|
|
|
|
|
>
|
|
|
|
|
<span>{ arg.Message }</span>
|
|
|
|
|
</div>
|
|
|
|
|
case ToastTypeError:
|
|
|
|
|
<div
|
|
|
|
|
class="card rounded-md bg-base-100 shadow-lg p-2 text-white bg-red-400"
|
|
|
|
|
_="on click add .hidden to #messages"
|
|
|
|
|
>
|
|
|
|
|
<span>{ arg.Message }</span>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-05 16:18:04 +00:00
|
|
|
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>
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-28 15:41:46 +00:00
|
|
|
type ButtonArgs struct {
|
2024-11-05 16:18:04 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-28 15:41:46 +00:00
|
|
|
templ Button(args ButtonArgs, attrs templ.Attributes) {
|
2024-11-05 16:18:04 +00:00
|
|
|
<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>
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-28 15:41:46 +00:00
|
|
|
templ MutedButton(args ButtonArgs, attrs templ.Attributes) {
|
2024-11-05 16:18:04 +00:00
|
|
|
<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 {
|
2025-01-14 15:51:29 +00:00
|
|
|
return templ.SafeURL(frm.CollectorPath(ctx, path))
|
2024-11-05 16:18:04 +00:00
|
|
|
}
|
|
|
|
|
|
2025-03-11 21:54:06 +00:00
|
|
|
type LabeledCheckboxArgs struct {
|
|
|
|
|
Label string // label value
|
|
|
|
|
Name string // field name attribute
|
|
|
|
|
Title string // field title attribute
|
|
|
|
|
ID string // field id attribute
|
|
|
|
|
Checked bool // value is checked
|
|
|
|
|
Required bool // field is required
|
|
|
|
|
Tooltip string // text tooltip
|
|
|
|
|
Hyperscript string // hyperscript associated with element
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LabeledCheckbox is a checkbox element with a label
|
|
|
|
|
templ LabeledCheckbox(args LabeledCheckboxArgs) {
|
|
|
|
|
<label class="fieldset-label">
|
|
|
|
|
<input
|
|
|
|
|
id={ args.ID }
|
|
|
|
|
name={ args.Name }
|
|
|
|
|
type="checkbox"
|
|
|
|
|
if args.Checked {
|
|
|
|
|
checked="checked"
|
|
|
|
|
}
|
|
|
|
|
if args.Required {
|
|
|
|
|
required
|
|
|
|
|
}
|
|
|
|
|
class="checkbox"
|
|
|
|
|
if args.Hyperscript != "" {
|
|
|
|
|
_={ args.Hyperscript }
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<span class="pl-3">{ args.Label }</span>
|
|
|
|
|
if args.Tooltip != "" {
|
|
|
|
|
<div class="tooltip tooltip-left px-3" data-tip={ args.Tooltip }>
|
|
|
|
|
<svg
|
|
|
|
|
data-src="https://unpkg.com/heroicons/20/solid/question-mark-circle.svg"
|
|
|
|
|
class="inline-block h-5 w-5 text-success"
|
|
|
|
|
></svg>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</label>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type LabeledSelectorArgs struct {
|
|
|
|
|
Label string // label value
|
|
|
|
|
LabelClass string // CSS class(es) for label
|
|
|
|
|
Name string // field name attribute
|
|
|
|
|
Title string // field title attribute
|
|
|
|
|
ID string // field id attribute
|
|
|
|
|
Options []selector.Option // seletor options
|
|
|
|
|
Required bool // field is required
|
|
|
|
|
Multiple bool // allow multiple
|
|
|
|
|
EditItems bool
|
|
|
|
|
Placeholder string
|
|
|
|
|
SearchDisabled bool
|
|
|
|
|
SelectionChangeEvent string // the event fired when a selection changes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LabeledSelector is a selector with a label
|
|
|
|
|
templ LabeledSelector(args LabeledSelectorArgs) {
|
|
|
|
|
<label
|
|
|
|
|
class={ fmt.Sprintf("flex flex-col h-fit w-full input input-bordered flex px-0 %s", args.LabelClass) }
|
|
|
|
|
>
|
|
|
|
|
<span class="inline-flex h-full group-[.label-group]:min-w-48 bg-primary-100 items-center rounded-tl-lg rounded-tr-lg px-3">{ args.Label }</span>
|
|
|
|
|
@selector.Selector(selector.SelectArgs{
|
|
|
|
|
Name: args.Name,
|
|
|
|
|
LabelClass: "rounded-tr-lg rounded-tl-lg",
|
|
|
|
|
ID: args.ID,
|
|
|
|
|
EditItems: args.EditItems,
|
|
|
|
|
Options: args.Options,
|
|
|
|
|
Multiple: args.Multiple,
|
|
|
|
|
Required: args.Required,
|
|
|
|
|
Placeholder: args.Placeholder,
|
|
|
|
|
SearchDisabled: args.SearchDisabled,
|
|
|
|
|
SelectionChangeEvent: args.SelectionChangeEvent,
|
|
|
|
|
})
|
|
|
|
|
</label>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type LabeledTextInputArgs struct {
|
|
|
|
|
Label string // label value
|
|
|
|
|
LabelClass string // CSS class for label
|
|
|
|
|
Name string // field name attribute
|
|
|
|
|
Title string // field title attribute
|
|
|
|
|
ID string // field id attribute
|
|
|
|
|
Placeholder string // field placeholder attribute
|
|
|
|
|
Value string // field value attribute
|
|
|
|
|
Required bool // field is required
|
|
|
|
|
Tooltip string // text tooltip
|
|
|
|
|
Hyperscript string // hyperscript associated with element
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LabeledTextInput is a text input with the label inside of it
|
|
|
|
|
templ LabeledTextInput(args LabeledTextInputArgs) {
|
|
|
|
|
<label class={ fmt.Sprintf("flex flex-col h-fit w-full %s", args.LabelClass) }>
|
|
|
|
|
<span class="inline-flex h-full w-full group-[.label-group]:min-w-48 bg-primary-100 rounded-tr-lg rounded-tl-lg items-center px-3">
|
|
|
|
|
{ args.Label }
|
|
|
|
|
if !args.Required {
|
|
|
|
|
<span class="badge badge-info bg-slate-100 mx-3">Optional</span>
|
|
|
|
|
}
|
|
|
|
|
</span>
|
|
|
|
|
<div class="flex items-center">
|
|
|
|
|
<input
|
|
|
|
|
class="bg-slate-100 w-full border-0 rounded-br-lg rounded-bl-lg"
|
|
|
|
|
type="text"
|
|
|
|
|
name={ args.Name }
|
|
|
|
|
if args.ID != "" {
|
|
|
|
|
id={ args.ID }
|
|
|
|
|
}
|
|
|
|
|
if args.Placeholder != "" {
|
|
|
|
|
placeholder={ args.Placeholder }
|
|
|
|
|
}
|
|
|
|
|
if args.Value != "" {
|
|
|
|
|
value={ args.Value }
|
|
|
|
|
}
|
|
|
|
|
if args.Required {
|
|
|
|
|
required
|
|
|
|
|
}
|
|
|
|
|
class="border-0"
|
|
|
|
|
if args.Hyperscript != "" {
|
|
|
|
|
_={ args.Hyperscript }
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
if args.Tooltip != "" {
|
|
|
|
|
<div class="tooltip tooltip-left px-3 py-3" data-tip={ args.Tooltip }>
|
|
|
|
|
<svg
|
|
|
|
|
data-src="https://unpkg.com/heroicons/20/solid/question-mark-circle.svg"
|
|
|
|
|
class="inline-block h-5 w-5 text-success"
|
|
|
|
|
></svg>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</label>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type FieldsetArgs struct {
|
|
|
|
|
Label string // the label of the field set
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
templ FieldSet(args FieldsetArgs) {
|
|
|
|
|
<fieldset class="fieldset flex flex-col gap-2 p-4 bg-base-100 border border-base-300 rounded-box w-full">
|
|
|
|
|
<legend class="fieldset-legend">{ args.Label }</legend>
|
|
|
|
|
{ children... }
|
|
|
|
|
</fieldset>
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-05 16:18:04 +00:00
|
|
|
// head simply provides the <head> element
|
2025-01-28 15:41:46 +00:00
|
|
|
templ Head(pageTitle string) {
|
2024-11-05 16:18:04 +00:00
|
|
|
<head>
|
2025-01-22 19:38:53 +00:00
|
|
|
<title>{ pageTitle }</title>
|
2024-11-05 16:18:04 +00:00
|
|
|
<meta charset="UTF-8"/>
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
|
|
|
@loadDependenciesOnce.Once() {
|
2025-01-14 15:51:29 +00:00
|
|
|
<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>
|
2025-01-23 19:28:24 +00:00
|
|
|
<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>
|
2024-11-05 16:18:04 +00:00
|
|
|
<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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2025-02-19 21:35:12 +00:00
|
|
|
// formValueChanged handles changes to user input in form fields
|
2024-11-05 16:18:04 +00:00
|
|
|
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
|
2025-02-19 21:35:12 +00:00
|
|
|
var watcherFieldElement = document.getElementById(watchingField.id) // the actual element watching the field
|
2025-02-20 17:28:33 +00:00
|
|
|
// 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]
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-11-05 16:18:04 +00:00
|
|
|
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;
|
2025-02-11 15:31:12 +00:00
|
|
|
case 'not':
|
|
|
|
|
match = watchingField.logic.trigger_values.some(val => newValue.toLowerCase() !== val.toLowerCase())
|
|
|
|
|
break;
|
2024-11-05 16:18:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2025-02-19 21:35:12 +00:00
|
|
|
el.classList.remove("hidden")
|
2024-11-05 16:18:04 +00:00
|
|
|
} else {
|
2025-02-19 21:35:12 +00:00
|
|
|
el.classList.add("hidden")
|
2024-11-05 16:18:04 +00:00
|
|
|
}
|
|
|
|
|
break;
|
2025-02-19 21:35:12 +00:00
|
|
|
case "field_logic_trigger_require":
|
|
|
|
|
if (match) {
|
|
|
|
|
el.classList.remove("hidden")
|
|
|
|
|
watcherFieldElement.setAttribute("required", "")
|
|
|
|
|
} else {
|
|
|
|
|
el.classList.add("hidden")
|
|
|
|
|
watcherFieldElement.removeAttribute("required")
|
|
|
|
|
}
|
|
|
|
|
break
|
2024-11-05 16:18:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</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">
|
2025-01-28 15:41:46 +00:00
|
|
|
@Head(pageTitle)
|
2025-01-10 18:52:12 +00:00
|
|
|
<body>
|
2025-03-26 21:29:25 +00:00
|
|
|
<div id="messages"></div>
|
2025-01-30 19:04:48 +00:00
|
|
|
<main>
|
2024-11-05 16:18:04 +00:00
|
|
|
{ children... }
|
|
|
|
|
</main>
|
|
|
|
|
@footer()
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-28 15:41:46 +00:00
|
|
|
// 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 {
|
2024-11-05 16:18:04 +00:00
|
|
|
p := ""
|
|
|
|
|
if len(path) == 1 {
|
|
|
|
|
p = path[0]
|
|
|
|
|
}
|
2025-01-28 15:41:46 +00:00
|
|
|
return T(F("%s%s", frm.BuilderPathForm(ctx, form.ID), p))
|
2024-11-05 16:18:04 +00:00
|
|
|
}
|
|
|
|
|
|
2025-01-28 15:41:46 +00:00
|
|
|
// FieldOptionsAsSelectorOptions returns all of a field's FieldOptions as selector.Options
|
|
|
|
|
func FieldOptionsAsSelectorOptions(form frm.Form, field types.FormField) (options []selector.Option) {
|
2024-11-05 16:18:04 +00:00
|
|
|
for _, option := range field.Options {
|
|
|
|
|
selected := false
|
|
|
|
|
for _, f := range form.Fields {
|
2025-02-17 21:14:23 +00:00
|
|
|
if f.Logic == nil || f.Logic.TriggerValues == nil || len(f.Logic.TriggerValues) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-02-18 21:25:01 +00:00
|
|
|
if f.Logic != nil && slices.Contains(f.Logic.TriggerValues, option.ID.String()) {
|
2024-11-05 16:18:04 +00:00
|
|
|
selected = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
options = append(options, selector.Option{
|
|
|
|
|
ID: option.ID,
|
|
|
|
|
Label: option.Label,
|
|
|
|
|
Value: option.ID.String(),
|
2025-02-17 21:14:23 +00:00
|
|
|
Order: option.Order,
|
2024-11-05 16:18:04 +00:00
|
|
|
Selected: selected,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-30 19:04:48 +00:00
|
|
|
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>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-05 16:18:04 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-28 15:41:46 +00:00
|
|
|
// F formats strings; shorthand for fmt.Sprintf
|
|
|
|
|
func F(s string, args ...any) string {
|
2024-12-30 18:49:56 +00:00
|
|
|
return fmt.Sprintf(s, args...)
|
|
|
|
|
}
|