frm/ui/common.templ

450 lines
16 KiB
Text
Raw Normal View History

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
}
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);
});
}
})
// 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
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]
}
}
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) {
el.classList.remove("hidden")
2024-11-05 16:18:04 +00:00
} else {
el.classList.add("hidden")
2024-11-05 16:18:04 +00:00
}
break;
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 {
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(),
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 {
return fmt.Sprintf(s, args...)
}