initial commit

This commit is contained in:
Adriano Caloiaro 2024-11-05 09:18:04 -07:00
commit caa95c66b6
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
46 changed files with 12151 additions and 0 deletions

45
.air.toml Normal file
View file

@ -0,0 +1,45 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
cmd = "go build -o ./tmp/frm cmd/dev_server/main.go"
delay = 100
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = ["internal/db.go", "internal/models.go", "internal/sqlc_query.sql.go", "internal/copyfrom.go", "internal/queries.sql.go"]
exclude_regex = ["_test.go", ".*_templ.go", ".*_enumer.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "templ", "html", "js", "css", "sql", "yaml" ]
include_file = ["css/tailwind.css", "sqlc_query.sql", "sqlc.yaml"]
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false
keep_scroll = true

15
.envrc Normal file
View file

@ -0,0 +1,15 @@
if ! has nix_direnv_version || ! nix_direnv_version 3.0.6; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.6/direnvrc" "sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM="
fi
watch_file flake.nix
watch_file flake.lock
watch_file .env
watch_file go.mod
watch_file go.lock
if ! use flake . --no-pure-eval
then
echo "devenv could not be built. The devenv environment was not loaded." >&2
fi

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.direnv
.devenv
.pre-commit-config.yaml
.env
tmp

13
README.md Normal file
View file

@ -0,0 +1,13 @@
# frm
frm is an embeddable HTML form builder for Go.
**This is a work in progress and not production-ready**
## Usage
<TBD>
## Inspiration
This project is heavily inspired by [opnform](https://github.com/jhumanj/opnform). OpnForm is very good, and if you don't have an _embeddable in Go_ requirement, then you should consider OpnForm instead.

84
cmd/dev_server/main.go Normal file
View file

@ -0,0 +1,84 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"time"
"github.com/acaloiaro/frm"
"github.com/acaloiaro/frm/frmchi"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httplog/v2"
)
var (
listenAddress = "0.0.0.0:3000"
requestLogger *httplog.Logger
logOptions *slog.HandlerOptions
logger *slog.Logger
)
func initLogging() {
initAppLogger()
initRequestLogger()
}
func initAppLogger() {
logLevel := os.Getenv("LOG_LEVEL")
logOptions = &slog.HandlerOptions{Level: slog.LevelDebug}
if logLevel == "INFO" {
logOptions.Level = slog.LevelInfo
}
logger = slog.New(slog.NewTextHandler(os.Stdout, logOptions))
}
func initRequestLogger() {
requestLogger = httplog.NewLogger("web", httplog.Options{
LogLevel: logOptions.Level.Level().Level(),
Concise: true,
RequestHeaders: false,
MessageFieldName: "message",
TimeFieldFormat: time.RFC3339,
Tags: map[string]string{
"env": "dev",
},
QuietDownRoutes: []string{
"/ping",
"/js/htmx.js",
"/js/htmx-response-targets.js",
"/js/hyperscript.js",
"/js/choices.min.js",
"/css/styles.css",
"/css/choices.min.css",
},
QuietDownPeriod: 24 * 60 * 60 * time.Second,
})
}
func main() {
initLogging()
logger.Info("frm dev server started")
router := chi.NewRouter()
router.Use(httplog.RequestLogger(requestLogger))
f := frm.New(frm.Args{
PostgresURL: os.Getenv("DATABASE_URL"),
})
err := f.Init(context.Background())
if err != nil {
panic(err)
}
frmchi.Mount(f, router, "/frm")
s := &http.Server{
Handler: router,
Addr: listenAddress,
ReadTimeout: time.Duration(10 * time.Second),
WriteTimeout: time.Duration(10 * time.Second),
}
err = s.ListenAndServe()
slog.Error("server exited", "error", err)
os.Exit(1)
}

91
db/create_forms_test.go Normal file
View file

@ -0,0 +1,91 @@
package db_test
import (
"context"
"fmt"
"os"
"reflect"
"testing"
"github.com/acaloiaro/frm/internal"
"github.com/acaloiaro/frm/types"
"github.com/google/uuid"
)
var (
postgresURL = os.Getenv("DATABASE_URL")
)
func TestCreateAndUpdateForm(t *testing.T) {
const nameUpdate = "Tell us about you"
fieldID := uuid.MustParse("1afd4bf9-42a4-4dfe-b359-d46a65ce5ba5")
fieldID2 := uuid.MustParse("2afd4bf9-42a4-4dfe-b359-d46a65ce5ba5")
fieldID3 := uuid.MustParse("3afd4bf9-42a4-4dfe-b359-d46a65ce5ba5")
fields := types.FormFields{
fieldID.String(): types.FormField{
ID: fieldID,
Label: "hello world",
Type: types.FormFieldTypeTextMultiple,
Required: true,
},
}
updatedFields := types.FormFields{
fieldID.String(): types.FormField{
ID: fieldID,
Order: 1,
Label: "What's your name?",
Placeholder: "Tell us your name",
Type: types.FormFieldTypeTextSingle,
Required: true,
},
fieldID2.String(): types.FormField{
ID: fieldID2,
Order: 2,
Label: "What are your view on bears?",
Type: types.FormFieldTypeTextMultiple,
Placeholder: "Tell us how you generally feel about bears",
Required: false,
},
fieldID3.String(): types.FormField{
ID: fieldID3,
Order: 3,
Label: "Which type of bear is best?",
Placeholder: "Choose a bear",
Type: types.FormFieldTypeMultiSelect,
Options: []types.Option{
{ID: uuid.New(), Label: "Black bear", Value: "Black bear", Selected: false},
{ID: uuid.New(), Label: "Brown bear", Value: "Brown bear", Selected: false},
{ID: uuid.New(), Label: "Blue bear", Value: "Blue bear", Selected: false},
},
Required: true,
},
}
ctx := context.Background()
f, err := internal.Q(ctx, postgresURL).SaveForm(ctx, internal.SaveFormParams{
ID: 1,
Name: "hello world",
Fields: fields,
})
if err != nil {
t.Error(err)
return
}
f, err = internal.Q(ctx, postgresURL).SaveForm(ctx, internal.SaveFormParams{
ID: 1,
Name: nameUpdate,
Fields: updatedFields,
})
if err != nil {
t.Error(err)
return
}
if f.Name != nameUpdate {
t.Error(fmt.Errorf("name did not update: %s!=%s", f.Name, nameUpdate))
}
if !reflect.DeepEqual(f.Fields, updatedFields) {
t.Error(fmt.Errorf("fields did not update: %v!=%v", f.Fields, updatedFields))
}
}

View file

@ -0,0 +1,2 @@
DROP TABLE forms;
DROP SEQUENCE IF EXISTS forms_ids;

View file

@ -0,0 +1,20 @@
-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- create the forms pk sequence
CREATE SEQUENCE form_ids START 1;
-- the forms table contains all forms
-- workspaces are to separate form ownership
CREATE TABLE IF NOT EXISTS forms (
id BIGINT PRIMARY KEY DEFAULT nextval('form_ids'),
workspace_id UUID NOT NULL DEFAULT uuid_generate_v4(), -- // workspace_id is used no namespace the form
name text not null,
fields jsonb default '{}' NOT NULL,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now())
);
COMMENT ON table forms IS 'Form contains all the data necesary to render a form';
COMMENT ON column forms.workspace_id IS 'a namespace for the form';
COMMENT ON column forms.fields IS 'all form fields serialized to JSON, see types.FormFields for structure details';

6
db/migrations/default.go Normal file
View file

@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS

19
db/queries.sql Normal file
View file

@ -0,0 +1,19 @@
-- name: ListForm :many
SELECT *
FROM forms;
-- name: GetForm :one
SELECT *
FROM forms
WHERE id = @id;
-- name: SaveForm :one
INSERT INTO forms (id, name, fields)
VALUES (coalesce(nullif(@id, 0), nextval('form_ids'))::bigint, @name, @fields) ON conflict(id) DO
UPDATE
SET updated_at = timezone('utc', now()),
name = @name,
fields = @fields RETURNING *;

0
env.sample Normal file
View file

2252
flake.lock Normal file

File diff suppressed because it is too large Load diff

209
flake.nix Normal file
View file

@ -0,0 +1,209 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
systems.url = "github:nix-systems/default";
devenv.url = "github:cachix/devenv";
tailwindcss.url = "github:acaloiaro/tailwind-cli-extra";
gomod2nix = {
url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
ess = {
url = "github:acaloiaro/ess/v2.13.0";
inputs.nixpkgs.follows = "nixpkgs";
};
air = {
url = "github:acaloiaro/air";
inputs.nixpkgs.follows = "nixpkgs";
};
templ.url = "github:a-h/templ/v0.2.793";
};
outputs = {
self,
nixpkgs,
devenv,
systems,
air,
gomod2nix,
tailwindcss,
...
} @ inputs: let
pkgs = nixpkgs.legacyPackages."x86_64-linux";
templ = system: inputs.templ.packages.${system}.templ;
forEachSystem = nixpkgs.lib.genAttrs (import systems);
in {
packages = forEachSystem (system: {
devenv-up = self.devShells.${system}.default.config.procfileScript;
});
devShells = forEachSystem (system: let
config = self.devShells.${system}.default.config;
executeEss = ''${inputs.ess.packages.${system}.default}/bin/ess --skip-git-add'';
postgresPort = 5434;
in {
default = devenv.lib.mkShell {
inherit inputs pkgs;
modules = [
{
languages = {
nix.enable = true;
go = {
enable = true;
package = pkgs.go_1_23;
};
};
packages = with pkgs; [
air.packages.${system}.default
go-migrate
gomod2nix.legacyPackages.${system}.gomod2nix
nixpacks
sqlc
postgresql
pre-commit
(templ system)
tailwindcss.packages.${system}.default
];
enterShell =
#bash
''
go install github.com/dmarkham/enumer@latest
export DATABASE_URL_NON_PGX="postgres://postgres:postgres@localhost:${toString postgresPort}/frm?sslmode=disable"
export DATABASE_URL="$DATABASE_URL_NON_PGX&pool_max_conns=100"
set +a
${executeEss}
## Fetch dependenies
HTMX_VERSION=2.0.3
HYPERSCRIPT_VERSION=0.9.13
CHOICES_DOT_JS_VERSION=11.0.2
if [ ! -f ./static/js/hyperscript.js ]; then
curl -sL --verbose "https://unpkg.com/hyperscript.org@$HYPERSCRIPT_VERSION" > ./static/js/hyperscript.js
fi
if [ ! -f ./static/js/htmx.js ]; then
curl -sL --verbose "https://unpkg.com/htmx.org@$HTMX_VERSION" > ./static/js/htmx.js
fi
if [ ! -f ./static/js/htmx-response-targets.js ]; then
curl -sL --verbose "https://unpkg.com/htmx.org/dist/ext/response-targets.js" > ./static/js/htmx-response-targets.js
fi
if [ ! -f ./static/js/choices.min.js ]; then
curl -sL --verbose "https://unpkg.com/choices.js@$CHOICES_DOT_JS_VERSION/public/assets/scripts/choices.min.js" > ./static/js/choices.min.js
fi
if [ ! -f ./static/css/choices.min.css ]; then
curl -sL --verbose "https://unpkg.com/choices.js@$CHOICES_DOT_JS_VERSION/public/assets/styles/choices.min.css" > ./static/css/choices.min.css
fi
run-show-help
'';
process.managers.process-compose.unixSocket.enable = true;
pre-commit.hooks.gomod2nix = {
enable = true;
always_run = true;
pass_filenames = false;
name = "gomod2nix";
description = "Run gomod2nix before commit";
entry = "${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix";
};
pre-commit.hooks.env-sample-sync = {
enable = true;
always_run = true;
pass_filenames = false;
name = "env-sample-sync";
description = "Sync secrets to env.sample";
entry = executeEss;
};
scripts = with pkgs; {
run-show-help = {
description = "Show this help text";
exec = ''
echo
echo Helper scripts available:
echo
${pkgs.gnused}/bin/sed -e 's| |XX|g' \
-e 's|=| |' <<EOF | \
${pkgs.util-linuxMinimal}/bin/column -t | \
${pkgs.gnused}/bin/sed -e 's|XX| |g'
${pkgs.lib.generators.toKeyValue {} (pkgs.lib.mapAttrs (name: value: value.description) config.scripts)}
EOF
echo
echo To start the web server and other jobs, run
echo
echo " devenv up"
echo
echo
'';
};
devdb = {
exec = "${postgresql}/bin/psql $DATABASE_URL_NON_PGX frm";
description = "Connect to the development database (local)";
};
exec-ess = {
exec = "${inputs.ess.packages.${system}.default}/bin/ess --skip-git-add";
description = "Execute 'ess' with default parameters";
};
server = {
description = "Run the development server";
exec = ''
tailwindcss -c ui/tailwind.config.js -i ./ui/css/tailwind.css -o ./static/css/styles.css && run-generate-models && go generate ./... && go build -o ./tmp/frm ./cmd/dev_server && ./tmp/frm
'';
};
migrate = {
description = "Use go-migrate to generate migrations";
exec = "${go-migrate}/bin/migrate -source file://./db/migrations -database $DATABASE_URL_NON_PGX $*";
};
run-generate-models = {
description = "Generate models from SQLc";
exec = ''
${sqlc}/bin/sqlc generate && echo sqlc generate done
'';
};
};
processes.air = {
exec = ''
${air.packages.${system}.default}/bin/air -c .air.toml -build.bin server
'';
};
processes.templ = {
exec = ''
templ generate --watch --proxy="http://localhost:3000"
'';
};
services = {
postgres = {
enable = true;
package = pkgs.postgresql_16;
listen_addresses = "127.0.0.1";
port = postgresPort;
initialScript = ''
CREATE ROLE postgres WITH PASSWORD 'postgres' SUPERUSER INHERIT CREATEROLE CREATEDB LOGIN REPLICATION BYPASSRLS;
CREATE DATABASE frm;
CREATE DATABASE frm_test;
'';
settings = {
max_connections = 250;
log_statement = "all";
};
};
};
}
];
};
});
};
}

41
frm.go Normal file
View file

@ -0,0 +1,41 @@
package frm
import (
"context"
"github.com/acaloiaro/frm/internal"
)
type Frm struct {
PostgresURL string // the database URL where forms are stored
}
type Args struct {
PostgresURL string // the database URL where forms are stored
}
// New initializes a new frm instance
//
// If the frm database hasn't been initiailized, the database is initialized
func New(args Args) *Frm {
return &Frm{
PostgresURL: args.PostgresURL,
}
}
func (f *Frm) Init(ctx context.Context) (err error) {
err = internal.InitializeDB(ctx, f.PostgresURL)
return
}
// GetForm retrieves a form by ID
func (f *Frm) GetForm(ctx context.Context, id int64) (form Form, err error) {
var frm internal.Form
frm, err = internal.Q(ctx, f.PostgresURL).GetForm(ctx, id)
if err != nil {
return
}
form = (Form)(frm)
return
}

6
frmchi/package.go Normal file
View file

@ -0,0 +1,6 @@
// package frmchi is for mounting frm to chi routers.
//
// The package name intentionally stutters because this package is likely be imported from package that already import
// a package named "chi"; otherwise, this package would also be named "chi". By users importing this package a "frmchi",
// we avoid the inevitable namespace conflict with "chi".
package frmchi

525
frmchi/router.go Normal file
View file

@ -0,0 +1,525 @@
package frmchi
import (
"fmt"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/acaloiaro/frm"
"github.com/acaloiaro/frm/handlers"
"github.com/acaloiaro/frm/internal"
"github.com/acaloiaro/frm/static"
"github.com/acaloiaro/frm/types"
"github.com/acaloiaro/frm/ui"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// This regex parses form field names of the following form
//
// [FIELD_UUID]FIELD_NAME
// Example: [2ad1591d-c852-47b5-a16d-0b90892421c8]label
//
// [FIELD_UUID][SUBGROUP_NAME]FIELD_NAME
// Example: [2ad1591d-c852-47b5-a16d-0b90892421c8][logic]target_field_id
var formFieldIDExtractor = regexp.MustCompile(`^\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12})\](\[(.+)\]){0,1}(.+){1}?$`)
// Mount mounts frm to the router at the given path
func Mount(f *frm.Frm, router chi.Router, mountPoint string) {
r := chi.NewRouter()
router.Mount(mountPoint, r)
r.Use(handlers.AddMountPointContext(mountPoint))
// any requests for which there are no defined chi routes are sent to the "file system"
// server, serving static content from the embedded filesystem
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.ReplaceAll(r.URL.Path, mountPoint, "")
http.FileServer(http.FS(static.Assets)).ServeHTTP(w, r)
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
forms, err := internal.Q(ctx, f.PostgresURL).ListForm(r.Context())
if err != nil {
slog.Error("unable to get forms", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
return
}
if len(forms) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
form := forms[0]
err = ui.Builder((frm.Form)(form)).Render(r.Context(), w)
if err != nil {
slog.Error("unable to render builder", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
}
})
r.Put("/{form_id}/fields/order", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
formID, err := strconv.ParseInt(chi.URLParam(r, "form_id"), 10, 64)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, formID)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
err = r.ParseForm()
if err != nil {
slog.Error("unable to parse form", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
return
}
updatedFields := types.FormFields{}
for key, field := range r.Form {
switch key {
case "order":
for order, fieldID := range field {
oldField := form.Fields[fieldID]
oldField.Order = order
updatedFields[fieldID] = oldField
}
default:
}
}
form.Fields = updatedFields
form, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
ID: form.ID,
Name: form.Name,
Fields: updatedFields,
})
if err != nil {
slog.Error("unable to save form", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the form fields form UI
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
err = ui.FormView((frm.Form)(form), true).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
})
r.Get("/{form_id}/logic_configurator/{field_id}/step3", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
formID, err := strconv.ParseInt(chi.URLParam(r, "form_id"), 10, 64)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
fieldID, err := uuid.Parse(chi.URLParam(r, "field_id"))
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
err = r.ParseForm()
if err != nil {
slog.Error("unable to parse form", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
return
}
// the URL param 'id' represents the _target_ field id
targetFieldID, err := uuid.Parse(r.Form.Get("id"))
if err != nil {
slog.Error("unable to parse chosen field id", slog.Any("error", err))
w.WriteHeader(http.StatusNotFound)
return
}
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, formID)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
targetField, ok := form.Fields[targetFieldID.String()]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
err = ui.LogicConfiguratorStepThree((frm.Form)(form), form.Fields[fieldID.String()], targetField).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(200)
})
r.Put("/{form_id}/settings", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
formID, err := strconv.ParseInt(chi.URLParam(r, "form_id"), 10, 64)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, formID)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
err = r.ParseForm()
if err != nil {
slog.Error("unable to parse form", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
return
}
formName := r.Form.Get("name")
if len(formName) == 0 {
formName = "New form"
}
form, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
ID: formID,
Name: formName,
Fields: form.Fields,
})
if err != nil {
slog.Error("unable to save new form field")
w.WriteHeader(http.StatusInternalServerError)
return
}
// Re-render the form fields form UI
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the form preview
err = ui.FormView((frm.Form)(form), true).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the configurator form
err = ui.FormFieldConfigurator((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the configurator form
err = ui.FormSettings((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render nav, so the title of the form updates
err = ui.FormBuilderNavTitle((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(200)
})
r.Post("/{form_id}/fields", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
formID, err := strconv.ParseInt(chi.URLParam(r, "form_id"), 10, 64)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, formID)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
err = r.ParseForm()
if err != nil {
slog.Error("unable to parse form", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
return
}
ft := r.Form.Get("field_type")
fieldType, err := types.FormFieldTypeString(ft)
if err != nil {
slog.Error("unknown form field type", "type", ft)
w.WriteHeader(http.StatusInternalServerError)
return
}
fields := form.Fields
order := len(fields) + 1 // place the new field at the end of the field list
fieldID := uuid.New()
newField := &types.FormField{ID: fieldID, Type: fieldType, Order: order}
switch fieldType {
case types.FormFieldTypeTextSingle:
newField.Label = "New text field"
newField.Placeholder = "Write some text"
case types.FormFieldTypeTextMultiple:
newField.Label = "New multi-line text field"
newField.Placeholder = "Write some text"
case types.FormFieldTypeSingleSelect:
newField.Label = "New select field"
newField.Placeholder = "Choose an item"
case types.FormFieldTypeMultiSelect:
newField.Label = "New multi select field"
newField.Placeholder = "Choose items item"
}
fields[fieldID.String()] = *newField
_, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
ID: formID,
Name: form.Name,
Fields: fields,
})
if err != nil {
slog.Error("unable to save new form field")
w.WriteHeader(http.StatusInternalServerError)
return
}
// Re-render the form fields form UI
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the form preview
err = ui.FormView((frm.Form)(form), true).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the configurator form
err = ui.FormFieldConfigurator((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(200)
})
r.Put("/{form_id}/fields", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
formID, err := strconv.ParseInt(chi.URLParam(r, "form_id"), 10, 64)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, formID)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
err = r.ParseForm()
if err != nil {
slog.Error("unable to parse form", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
return
}
updatedFields := form.Fields
// clear out the logic fields, ensuring logic only gets configured when the UI reflects fully configured logic
for fid, f := range updatedFields {
f.Logic = nil
updatedFields[fid] = f
}
for formFieldName, formFieldValues := range r.Form {
matches := formFieldIDExtractor.FindStringSubmatch(formFieldName)
if len(matches) < 4 {
continue
}
fieldID := matches[1]
fieldGroup := matches[3]
fieldName := matches[4]
fieldValues := formFieldValues
isset := len(fieldValues) > 0
if !isset {
continue
}
switch {
case fieldName == "required":
val := (len(fieldValues) > 1 && fieldValues[1] == "on") || (len(fieldValues) > 0 && fieldValues[0] == "on")
oldField := form.Fields[fieldID]
oldField.Required = val
updatedFields[fieldID] = oldField
case fieldName == "hidden":
val := (len(fieldValues) > 1 && fieldValues[1] == "on") || (len(fieldValues) > 0 && fieldValues[0] == "on")
oldField := form.Fields[fieldID]
oldField.Hidden = val
updatedFields[fieldID] = oldField
case fieldName == "label":
oldField := form.Fields[fieldID]
oldField.Label = fieldValues[0]
updatedFields[fieldID] = oldField
case fieldName == "placeholder":
oldField := form.Fields[fieldID]
oldField.Placeholder = fieldValues[0]
updatedFields[fieldID] = oldField
case fieldName == "options":
oldField := form.Fields[fieldID]
oldField.Options = toFormFieldOption(oldField, fieldValues)
updatedFields[fieldID] = oldField
case fieldGroup == ui.FieldGroupLogic && fieldName == ui.FieldLogicTargetFieldID:
targetFieldID, err := uuid.Parse(fieldValues[0])
if err != nil {
continue
}
oldField := form.Fields[fieldID]
if oldField.Logic == nil {
oldField.Logic = &types.FieldLogic{}
}
oldField.Logic.TargetFieldID = targetFieldID
updatedFields[fieldID] = oldField
case fieldGroup == ui.FieldGroupLogic && fieldName == ui.FieldLogicTargetFieldValue:
oldField := form.Fields[fieldID]
if oldField.Logic == nil {
oldField.Logic = &types.FieldLogic{}
}
oldField.Logic.TriggerValues = fieldValues
updatedFields[fieldID] = oldField
case fieldGroup == ui.FieldGroupLogic && fieldName == ui.FieldLogicComparator:
oldField := form.Fields[fieldID]
if oldField.Logic == nil {
oldField.Logic = &types.FieldLogic{}
}
oldField.Logic.TriggerComparator, _ = types.FieldLogicComparatorString(fieldValues[0])
updatedFields[fieldID] = oldField
case fieldGroup == ui.FieldGroupLogic && fieldName == types.FieldLogicTriggerShow.String():
oldField := form.Fields[fieldID]
if oldField.Logic == nil {
oldField.Logic = &types.FieldLogic{}
}
if len(fieldValues) > 0 && fieldValues[0] == "on" {
oldField.Logic.TriggerActions = []types.FieldLogicTriggerAction{types.FieldLogicTriggerShow}
}
updatedFields[fieldID] = oldField
}
}
form.Fields = updatedFields
form, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
ID: form.ID,
Name: form.Name,
Fields: updatedFields,
})
if err != nil {
slog.Error("unable to save form", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the form fields form UI
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the form preview
err = ui.FormView((frm.Form)(form), true).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
})
r.Delete("/{form_id}/fields/{field_id}", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
formID, err := strconv.ParseInt(chi.URLParam(r, "form_id"), 10, 64)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
fieldID, err := uuid.Parse(chi.URLParam(r, "field_id"))
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, formID)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
updatedFields := form.Fields
delete(updatedFields, fmt.Sprint(fieldID))
form.Fields = updatedFields
form, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
ID: form.ID,
Name: form.Name,
Fields: updatedFields,
})
if err != nil {
slog.Error("unable to delete form field", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the form fields form UI
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the form preview
err = ui.FormView((frm.Form)(form), true).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
// Re-render the configurator form
err = ui.FormFieldConfigurator((frm.Form)(form)).Render(ctx, w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
})
}
// toFormFieldOption takes a list of options sa strings and determines whether the string options represent new options
// being created, in which case an ID/value must be generated for the option, or if the option is amongst the existing
// options for the field being updated.
func toFormFieldOption(field types.FormField, options []string) types.FieldOptions {
fieldOptions := types.FieldOptions{}
for _, option := range options {
var id uuid.UUID
optionID, err := uuid.Parse(option)
if err != nil {
id = uuid.New()
fieldOptions = append(fieldOptions, types.Option{
ID: id,
Value: id.String(),
Label: option,
})
} else {
for _, opt := range field.Options {
if opt.ID == optionID {
fieldOptions = append(fieldOptions, opt)
}
}
}
}
return fieldOptions
}

25
go.mod Normal file
View file

@ -0,0 +1,25 @@
module github.com/acaloiaro/frm
go 1.23.2
require (
github.com/a-h/templ v0.2.793
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/httplog/v2 v2.1.1
github.com/golang-migrate/migrate/v4 v4.18.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.1
)
require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/text v0.18.0 // indirect
)

99
go.sum Normal file
View file

@ -0,0 +1,99 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.2 h1:QVMkw1lN4UXli3xwh20AD/YvGcBEZufEfkfXKI06d2Q=
github.com/dhui/dktest v0.4.2/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/httplog/v2 v2.1.1 h1:ojojiu4PIaoeJ/qAO4GWUxJqvYUTobeo7zmuHQJAxRk=
github.com/go-chi/httplog/v2 v2.1.1/go.mod h1:/XXdxicJsp4BA5fapgIC3VuTD+z0Z/VzukoB3VDc1YE=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.18.0 h1:X3ewdsmKVhsMx5RB3jojlqoNFiv4ToU48ZLX2sL4XZI=
github.com/golang-migrate/migrate/v4 v4.18.0/go.mod h1:c9zaf41tfUCT06GH9kw3iAsKhkkNEpHTirpKKNtoa5w=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

51
gomod2nix.toml Normal file
View file

@ -0,0 +1,51 @@
schema = 3
[mod]
[mod."github.com/a-h/templ"]
version = "v0.2.793"
hash = "sha256-WThPVkb56osTrMJZf0WIoqbsM9DHGaZsG47MS0eoseA="
[mod."github.com/go-chi/chi/v5"]
version = "v5.1.0"
hash = "sha256-DpVfbPlJe4zQNdBrirWq9Q37/awSu5zb3IJgcuyEED8="
[mod."github.com/go-chi/httplog/v2"]
version = "v2.1.1"
hash = "sha256-bMpoHUSNk3Uds9NfrStwhDsdCONR4pJso9sVUhqfidk="
[mod."github.com/golang-migrate/migrate/v4"]
version = "v4.18.0"
hash = "sha256-SVLDnZKTZ5IJvcDIqv8FEXVFbM2PgbPEVRMpFGhPUsM="
[mod."github.com/google/uuid"]
version = "v1.6.0"
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
[mod."github.com/hashicorp/errwrap"]
version = "v1.1.0"
hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw="
[mod."github.com/hashicorp/go-multierror"]
version = "v1.1.1"
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
[mod."github.com/jackc/pgpassfile"]
version = "v1.0.0"
hash = "sha256-H0nFbC34/3pZUFnuiQk9W7yvAMh6qJDrqvHp+akBPLM="
[mod."github.com/jackc/pgservicefile"]
version = "v0.0.0-20240606120523-5a60cdf6a761"
hash = "sha256-ETpGsLAA2wcm5xJBayr/mZrCE1YsWbnkbSSX3ptrFn0="
[mod."github.com/jackc/pgx/v5"]
version = "v5.7.1"
hash = "sha256-N0aiHG91gMztyWC3PAEG0JVd6B/yrcOCfdl0NzqCDXk="
[mod."github.com/jackc/puddle/v2"]
version = "v2.2.2"
hash = "sha256-IUxdu4JYfsCh/qlz2SiUWu7EVPHhyooiVA4oaS2Z6yk="
[mod."github.com/lib/pq"]
version = "v1.10.9"
hash = "sha256-Gl6dLtL+yk6UrTTWfas43aM4lP/pNa2l7+ITXnjQyKs="
[mod."go.uber.org/atomic"]
version = "v1.7.0"
hash = "sha256-g83RSzO/5k8W3qyiIsrQbVq1F8qJGccdWEXTjkqUOfc="
[mod."golang.org/x/crypto"]
version = "v0.27.0"
hash = "sha256-8HP4+gr4DbXI22GhdgZmCWr1ijtI9HNLsTcE0kltY9o="
[mod."golang.org/x/sync"]
version = "v0.8.0"
hash = "sha256-usvF0z7gq1vsX58p4orX+8WHlv52pdXgaueXlwj2Wss="
[mod."golang.org/x/text"]
version = "v0.18.0"
hash = "sha256-aNvJW4gQs+MTfdz6DZqyyHQS2GJ9W8L8qKPVODPn4+k="

25
handlers/handlers.go Normal file
View file

@ -0,0 +1,25 @@
package handlers
import (
"context"
"net/http"
)
type contextKey string
const (
MountPointContextKey contextKey = "mount_point_context_key"
)
// AddMountPointContext adds the mount point where frm is mounted to the request context
func AddMountPointContext(mountPoint string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, MountPointContextKey, mountPoint)
h.ServeHTTP(w, r.Clone(ctx))
}
return http.HandlerFunc(fn)
}
}

32
internal/db.go Normal file
View file

@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package internal
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

148
internal/default.go Normal file
View file

@ -0,0 +1,148 @@
package internal
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/acaloiaro/frm/db/migrations"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var pool *pgxpool.Pool
// getPool returns a database pool for the specified connection string
func getPool(ctx context.Context, databaseURL string) (p *pgxpool.Pool, err error) {
if pool == nil {
var poolConfig *pgxpool.Config
poolConfig, err = pgxpool.ParseConfig(databaseURL)
if err != nil {
err = fmt.Errorf("invalid connection string: %v", err)
return
}
err = InitializeDB(ctx, databaseURL)
if err != nil {
err = fmt.Errorf("database failed to initialize: %v", err)
return
}
p, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
if err != nil {
err = fmt.Errorf("invalid connection string: %v", err)
return
}
} else {
p = pool
}
return
}
// Q returns a DBTX instance for querying the frm database
func Q(ctx context.Context, postgresURL string) *Queries {
p, err := getPool(ctx, postgresURL)
if err != nil {
slog.Error("database pool unavailable", "error", err)
return nil // TODO return a no-op DBTX to avoid NPEs
}
return New(p)
}
// InitializeDB creates the application database if it doesn't exist and runs all migrations against it
//
// databaseUrl is the database URL string to connect to the database
func InitializeDB(ctx context.Context, postgresURL string) (err error) {
postgresURL, err = pgConnectionString(postgresURL, true)
if err != nil {
return fmt.Errorf("invalid connection string: %v", err)
}
var cfg *pgx.ConnConfig
cfg, err = pgx.ParseConfig(postgresURL)
if err != nil {
return
}
err = createIfNotExist(ctx, cfg)
if err != nil {
return
}
err = runMigrations(postgresURL)
return
}
// TODO not all users wil have permission to do this. Fail gracefully when user lacks permission
func createIfNotExist(ctx context.Context, cfg *pgx.ConnConfig) (err error) {
dbName := cfg.Database
cfg.Database = ""
cfg.RuntimeParams = nil
conn, err := pgx.ConnectConfig(context.Background(), cfg)
if err != nil {
return
}
var exists int
err = conn.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname=$1", dbName).Scan(&exists)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("unable to create database: %w", err)
}
if exists != 1 {
// note: prepared statements are not supported by pgx for CREATE DATABASE queries
_, err = conn.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s", dbName))
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("unable to create database: %w", err)
}
}
return
}
// runMigrations runs all available migrations if steps are unspecified ( 0 ), and runs either up steps or down steps
func runMigrations(postgresURL string) (err error) {
ms, err := iofs.New(migrations.FS, ".")
if err != nil {
panic(fmt.Sprintf("unable to run migrations: %v", err))
}
m, err := migrate.NewWithSourceInstance("iofs", ms, postgresURL)
if err != nil {
panic(fmt.Sprintf("unable to run migrations: %v", err))
}
// We don't need the migration tooling to hold it's connections to the DB once it has been completed.
defer m.Close()
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
err = fmt.Errorf("unable to run migrations!!!: %v", err)
} else {
err = nil
}
return
}
func pgConnectionString(postgresURL string, disableSSL bool, options ...string) (connString string, err error) {
var cfg *pgx.ConnConfig
cfg, err = pgx.ParseConfig(postgresURL)
if err != nil {
return
}
options = append(options, "x-migrations-table=frm_migrations")
if disableSSL {
options = append(options, "sslmode=disable")
}
connString = fmt.Sprintf("postgres://%s:%s@%s:%d/%s?%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database, strings.Join(options, "&"))
return
}

24
internal/models.go Normal file
View file

@ -0,0 +1,24 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package internal
import (
"time"
"github.com/acaloiaro/frm/types"
uuid "github.com/google/uuid"
)
// Form contains all the data necesary to render a form
type Form struct {
ID int64 `json:"id"`
// a namespace for the form
WorkspaceID uuid.UUID `json:"workspace_id"`
Name string `json:"name"`
// all form fields serialized to JSON, see types.FormFields for structure details
Fields types.FormFields `json:"fields"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

113
internal/queries.sql.go Normal file
View file

@ -0,0 +1,113 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: queries.sql
package internal
import (
"context"
"github.com/acaloiaro/frm/types"
)
const getForm = `-- name: GetForm :one
SELECT id, workspace_id, name, fields, created_at, updated_at
FROM forms
WHERE id = $1
`
// GetForm
//
// SELECT id, workspace_id, name, fields, created_at, updated_at
// FROM forms
// WHERE id = $1
func (q *Queries) GetForm(ctx context.Context, id int64) (Form, error) {
row := q.db.QueryRow(ctx, getForm, id)
var i Form
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Fields,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listForm = `-- name: ListForm :many
SELECT id, workspace_id, name, fields, created_at, updated_at
FROM forms
`
// ListForm
//
// SELECT id, workspace_id, name, fields, created_at, updated_at
// FROM forms
func (q *Queries) ListForm(ctx context.Context) ([]Form, error) {
rows, err := q.db.Query(ctx, listForm)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Form
for rows.Next() {
var i Form
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Fields,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const saveForm = `-- name: SaveForm :one
INSERT INTO forms (id, name, fields)
VALUES (coalesce(nullif($1, 0), nextval('form_ids'))::bigint, $2, $3) ON conflict(id) DO
UPDATE
SET updated_at = timezone('utc', now()),
name = $2,
fields = $3 RETURNING id, workspace_id, name, fields, created_at, updated_at
`
type SaveFormParams struct {
ID interface{} `json:"id"`
Name string `json:"name"`
Fields types.FormFields `json:"fields"`
}
// SaveForm
//
// INSERT INTO forms (id, name, fields)
// VALUES (coalesce(nullif($1, 0), nextval('form_ids'))::bigint, $2, $3) ON conflict(id) DO
// UPDATE
// SET updated_at = timezone('utc', now()),
// name = $2,
// fields = $3 RETURNING id, workspace_id, name, fields, created_at, updated_at
func (q *Queries) SaveForm(ctx context.Context, arg SaveFormParams) (Form, error) {
row := q.db.QueryRow(ctx, saveForm, arg.ID, arg.Name, arg.Fields)
var i Form
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Fields,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

53
sqlc.yaml Normal file
View file

@ -0,0 +1,53 @@
version: 2
overrides:
go:
overrides:
- db_type: uuid
nullable: true
go_type:
import: github.com/google/uuid
package: uuid
type: UUID
#
# Note migrations must use `timestamptz` instead of `timestamp with time zone` due to https://github.com/sqlc-dev/sqlc/issues/2630
#
- db_type: timestamptz
engine: postgresql
go_type:
import: time
type: Time
- db_type: timestamptz
nullable: true
engine: postgresql
go_type:
import: gopkg.in/guregu/null.v4
package: null
type: Time
- db_type: pg_catalog.interval
engine: postgresql
go_type:
import: time
type: Duration
- column: forms.fields
go_type:
import: github.com/acaloiaro/frm/types
type: FormFields
- column: forms.workspace_id
go_type:
import: github.com/google/uuid
package: uuid
type: UUID
sql:
- engine: postgresql
queries: db/queries.sql
schema: db/migrations
gen:
go:
package: internal
out: ./internal
sql_package: pgx/v5
emit_pointers_for_null_types: true
emit_exported_queries: false
emit_json_tags: true
emit_sql_as_comment: true

6
static/assets.go Normal file
View file

@ -0,0 +1,6 @@
package static
import "embed"
//go:embed js/* css/*
var Assets embed.FS

1
static/css/choices.min.css vendored Normal file

File diff suppressed because one or more lines are too long

3065
static/css/styles.css Normal file

File diff suppressed because it is too large Load diff

2
static/js/choices.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,130 @@
(function(){
/** @type {import("../htmx").HtmxInternalApi} */
var api;
var attrPrefix = 'hx-target-';
// IE11 doesn't support string.startsWith
function startsWith(str, prefix) {
return str.substring(0, prefix.length) === prefix
}
/**
* @param {HTMLElement} elt
* @param {number} respCode
* @returns {HTMLElement | null}
*/
function getRespCodeTarget(elt, respCodeNumber) {
if (!elt || !respCodeNumber) return null;
var respCode = respCodeNumber.toString();
// '*' is the original syntax, as the obvious character for a wildcard.
// The 'x' alternative was added for maximum compatibility with HTML
// templating engines, due to ambiguity around which characters are
// supported in HTML attributes.
//
// Start with the most specific possible attribute and generalize from
// there.
var attrPossibilities = [
respCode,
respCode.substr(0, 2) + '*',
respCode.substr(0, 2) + 'x',
respCode.substr(0, 1) + '*',
respCode.substr(0, 1) + 'x',
respCode.substr(0, 1) + '**',
respCode.substr(0, 1) + 'xx',
'*',
'x',
'***',
'xxx',
];
if (startsWith(respCode, '4') || startsWith(respCode, '5')) {
attrPossibilities.push('error');
}
for (var i = 0; i < attrPossibilities.length; i++) {
var attr = attrPrefix + attrPossibilities[i];
var attrValue = api.getClosestAttributeValue(elt, attr);
if (attrValue) {
if (attrValue === "this") {
return api.findThisElement(elt, attr);
} else {
return api.querySelectorExt(elt, attrValue);
}
}
}
return null;
}
/** @param {Event} evt */
function handleErrorFlag(evt) {
if (evt.detail.isError) {
if (htmx.config.responseTargetUnsetsError) {
evt.detail.isError = false;
}
} else if (htmx.config.responseTargetSetsError) {
evt.detail.isError = true;
}
}
htmx.defineExtension('response-targets', {
/** @param {import("../htmx").HtmxInternalApi} apiRef */
init: function (apiRef) {
api = apiRef;
if (htmx.config.responseTargetUnsetsError === undefined) {
htmx.config.responseTargetUnsetsError = true;
}
if (htmx.config.responseTargetSetsError === undefined) {
htmx.config.responseTargetSetsError = false;
}
if (htmx.config.responseTargetPrefersExisting === undefined) {
htmx.config.responseTargetPrefersExisting = false;
}
if (htmx.config.responseTargetPrefersRetargetHeader === undefined) {
htmx.config.responseTargetPrefersRetargetHeader = true;
}
},
/**
* @param {string} name
* @param {Event} evt
*/
onEvent: function (name, evt) {
if (name === "htmx:beforeSwap" &&
evt.detail.xhr &&
evt.detail.xhr.status !== 200) {
if (evt.detail.target) {
if (htmx.config.responseTargetPrefersExisting) {
evt.detail.shouldSwap = true;
handleErrorFlag(evt);
return true;
}
if (htmx.config.responseTargetPrefersRetargetHeader &&
evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) {
evt.detail.shouldSwap = true;
handleErrorFlag(evt);
return true;
}
}
if (!evt.detail.requestConfig) {
return true;
}
var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status);
if (target) {
handleErrorFlag(evt);
evt.detail.shouldSwap = true;
evt.detail.target = target;
}
return true;
}
}
});
})();

1
static/js/htmx.js Normal file

File diff suppressed because one or more lines are too long

1
static/js/hyperscript.js Normal file

File diff suppressed because one or more lines are too long

20
types.go Normal file
View file

@ -0,0 +1,20 @@
package frm
import (
"encoding/json"
"github.com/acaloiaro/frm/internal"
)
// Form is a form
type Form internal.Form
// JSON returns the form's JSON-seralized string representation
func (f Form) JSON() string {
b, err := json.Marshal(f)
if err != nil {
return ""
}
return string(b)
}

View file

@ -0,0 +1,108 @@
// Code generated by "enumer -type FieldLogicComparator -trimprefix FieldLogicComparator -transform=snake -json -text"; DO NOT EDIT.
package types
import (
"encoding/json"
"fmt"
"strings"
)
const _FieldLogicComparatorName = "equalcontains"
var _FieldLogicComparatorIndex = [...]uint8{0, 5, 13}
const _FieldLogicComparatorLowerName = "equalcontains"
func (i FieldLogicComparator) String() string {
if i < 0 || i >= FieldLogicComparator(len(_FieldLogicComparatorIndex)-1) {
return fmt.Sprintf("FieldLogicComparator(%d)", i)
}
return _FieldLogicComparatorName[_FieldLogicComparatorIndex[i]:_FieldLogicComparatorIndex[i+1]]
}
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
func _FieldLogicComparatorNoOp() {
var x [1]struct{}
_ = x[FieldLogicComparatorEqual-(0)]
_ = x[FieldLogicComparatorContains-(1)]
}
var _FieldLogicComparatorValues = []FieldLogicComparator{FieldLogicComparatorEqual, FieldLogicComparatorContains}
var _FieldLogicComparatorNameToValueMap = map[string]FieldLogicComparator{
_FieldLogicComparatorName[0:5]: FieldLogicComparatorEqual,
_FieldLogicComparatorLowerName[0:5]: FieldLogicComparatorEqual,
_FieldLogicComparatorName[5:13]: FieldLogicComparatorContains,
_FieldLogicComparatorLowerName[5:13]: FieldLogicComparatorContains,
}
var _FieldLogicComparatorNames = []string{
_FieldLogicComparatorName[0:5],
_FieldLogicComparatorName[5:13],
}
// FieldLogicComparatorString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func FieldLogicComparatorString(s string) (FieldLogicComparator, error) {
if val, ok := _FieldLogicComparatorNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _FieldLogicComparatorNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to FieldLogicComparator values", s)
}
// FieldLogicComparatorValues returns all values of the enum
func FieldLogicComparatorValues() []FieldLogicComparator {
return _FieldLogicComparatorValues
}
// FieldLogicComparatorStrings returns a slice of all String values of the enum
func FieldLogicComparatorStrings() []string {
strs := make([]string, len(_FieldLogicComparatorNames))
copy(strs, _FieldLogicComparatorNames)
return strs
}
// IsAFieldLogicComparator returns "true" if the value is listed in the enum definition. "false" otherwise
func (i FieldLogicComparator) IsAFieldLogicComparator() bool {
for _, v := range _FieldLogicComparatorValues {
if i == v {
return true
}
}
return false
}
// MarshalJSON implements the json.Marshaler interface for FieldLogicComparator
func (i FieldLogicComparator) MarshalJSON() ([]byte, error) {
return json.Marshal(i.String())
}
// UnmarshalJSON implements the json.Unmarshaler interface for FieldLogicComparator
func (i *FieldLogicComparator) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return fmt.Errorf("FieldLogicComparator should be a string, got %s", data)
}
var err error
*i, err = FieldLogicComparatorString(s)
return err
}
// MarshalText implements the encoding.TextMarshaler interface for FieldLogicComparator
func (i FieldLogicComparator) MarshalText() ([]byte, error) {
return []byte(i.String()), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface for FieldLogicComparator
func (i *FieldLogicComparator) UnmarshalText(text []byte) error {
var err error
*i, err = FieldLogicComparatorString(string(text))
return err
}

View file

@ -0,0 +1,104 @@
// Code generated by "enumer -type FieldLogicTriggerAction -trimprefix FieldLogicTriggerAction -transform=snake -json -text"; DO NOT EDIT.
package types
import (
"encoding/json"
"fmt"
"strings"
)
const _FieldLogicTriggerActionName = "field_logic_trigger_show"
var _FieldLogicTriggerActionIndex = [...]uint8{0, 24}
const _FieldLogicTriggerActionLowerName = "field_logic_trigger_show"
func (i FieldLogicTriggerAction) String() string {
if i < 0 || i >= FieldLogicTriggerAction(len(_FieldLogicTriggerActionIndex)-1) {
return fmt.Sprintf("FieldLogicTriggerAction(%d)", i)
}
return _FieldLogicTriggerActionName[_FieldLogicTriggerActionIndex[i]:_FieldLogicTriggerActionIndex[i+1]]
}
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
func _FieldLogicTriggerActionNoOp() {
var x [1]struct{}
_ = x[FieldLogicTriggerShow-(0)]
}
var _FieldLogicTriggerActionValues = []FieldLogicTriggerAction{FieldLogicTriggerShow}
var _FieldLogicTriggerActionNameToValueMap = map[string]FieldLogicTriggerAction{
_FieldLogicTriggerActionName[0:24]: FieldLogicTriggerShow,
_FieldLogicTriggerActionLowerName[0:24]: FieldLogicTriggerShow,
}
var _FieldLogicTriggerActionNames = []string{
_FieldLogicTriggerActionName[0:24],
}
// FieldLogicTriggerActionString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func FieldLogicTriggerActionString(s string) (FieldLogicTriggerAction, error) {
if val, ok := _FieldLogicTriggerActionNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _FieldLogicTriggerActionNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to FieldLogicTriggerAction values", s)
}
// FieldLogicTriggerActionValues returns all values of the enum
func FieldLogicTriggerActionValues() []FieldLogicTriggerAction {
return _FieldLogicTriggerActionValues
}
// FieldLogicTriggerActionStrings returns a slice of all String values of the enum
func FieldLogicTriggerActionStrings() []string {
strs := make([]string, len(_FieldLogicTriggerActionNames))
copy(strs, _FieldLogicTriggerActionNames)
return strs
}
// IsAFieldLogicTriggerAction returns "true" if the value is listed in the enum definition. "false" otherwise
func (i FieldLogicTriggerAction) IsAFieldLogicTriggerAction() bool {
for _, v := range _FieldLogicTriggerActionValues {
if i == v {
return true
}
}
return false
}
// MarshalJSON implements the json.Marshaler interface for FieldLogicTriggerAction
func (i FieldLogicTriggerAction) MarshalJSON() ([]byte, error) {
return json.Marshal(i.String())
}
// UnmarshalJSON implements the json.Unmarshaler interface for FieldLogicTriggerAction
func (i *FieldLogicTriggerAction) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return fmt.Errorf("FieldLogicTriggerAction should be a string, got %s", data)
}
var err error
*i, err = FieldLogicTriggerActionString(s)
return err
}
// MarshalText implements the encoding.TextMarshaler interface for FieldLogicTriggerAction
func (i FieldLogicTriggerAction) MarshalText() ([]byte, error) {
return []byte(i.String()), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface for FieldLogicTriggerAction
func (i *FieldLogicTriggerAction) UnmarshalText(text []byte) error {
var err error
*i, err = FieldLogicTriggerActionString(string(text))
return err
}

View file

@ -0,0 +1,104 @@
// Code generated by "enumer -type FormFieldType -trimprefix FormFieldType -transform=snake -json"; DO NOT EDIT.
package types
import (
"encoding/json"
"fmt"
"strings"
)
const _FormFieldTypeName = "text_singletext_multiplesingle_selectmulti_select"
var _FormFieldTypeIndex = [...]uint8{0, 11, 24, 37, 49}
const _FormFieldTypeLowerName = "text_singletext_multiplesingle_selectmulti_select"
func (i FormFieldType) String() string {
if i < 0 || i >= FormFieldType(len(_FormFieldTypeIndex)-1) {
return fmt.Sprintf("FormFieldType(%d)", i)
}
return _FormFieldTypeName[_FormFieldTypeIndex[i]:_FormFieldTypeIndex[i+1]]
}
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
func _FormFieldTypeNoOp() {
var x [1]struct{}
_ = x[FormFieldTypeTextSingle-(0)]
_ = x[FormFieldTypeTextMultiple-(1)]
_ = x[FormFieldTypeSingleSelect-(2)]
_ = x[FormFieldTypeMultiSelect-(3)]
}
var _FormFieldTypeValues = []FormFieldType{FormFieldTypeTextSingle, FormFieldTypeTextMultiple, FormFieldTypeSingleSelect, FormFieldTypeMultiSelect}
var _FormFieldTypeNameToValueMap = map[string]FormFieldType{
_FormFieldTypeName[0:11]: FormFieldTypeTextSingle,
_FormFieldTypeLowerName[0:11]: FormFieldTypeTextSingle,
_FormFieldTypeName[11:24]: FormFieldTypeTextMultiple,
_FormFieldTypeLowerName[11:24]: FormFieldTypeTextMultiple,
_FormFieldTypeName[24:37]: FormFieldTypeSingleSelect,
_FormFieldTypeLowerName[24:37]: FormFieldTypeSingleSelect,
_FormFieldTypeName[37:49]: FormFieldTypeMultiSelect,
_FormFieldTypeLowerName[37:49]: FormFieldTypeMultiSelect,
}
var _FormFieldTypeNames = []string{
_FormFieldTypeName[0:11],
_FormFieldTypeName[11:24],
_FormFieldTypeName[24:37],
_FormFieldTypeName[37:49],
}
// FormFieldTypeString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func FormFieldTypeString(s string) (FormFieldType, error) {
if val, ok := _FormFieldTypeNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _FormFieldTypeNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to FormFieldType values", s)
}
// FormFieldTypeValues returns all values of the enum
func FormFieldTypeValues() []FormFieldType {
return _FormFieldTypeValues
}
// FormFieldTypeStrings returns a slice of all String values of the enum
func FormFieldTypeStrings() []string {
strs := make([]string, len(_FormFieldTypeNames))
copy(strs, _FormFieldTypeNames)
return strs
}
// IsAFormFieldType returns "true" if the value is listed in the enum definition. "false" otherwise
func (i FormFieldType) IsAFormFieldType() bool {
for _, v := range _FormFieldTypeValues {
if i == v {
return true
}
}
return false
}
// MarshalJSON implements the json.Marshaler interface for FormFieldType
func (i FormFieldType) MarshalJSON() ([]byte, error) {
return json.Marshal(i.String())
}
// UnmarshalJSON implements the json.Unmarshaler interface for FormFieldType
func (i *FormFieldType) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return fmt.Errorf("FormFieldType should be a string, got %s", data)
}
var err error
*i, err = FormFieldTypeString(s)
return err
}

138
types/types.go Normal file
View file

@ -0,0 +1,138 @@
package types
import (
"encoding/json"
"github.com/google/uuid"
)
// FormFieldType enum enumerates all possible form field types
//
//go:generate enumer -type FormFieldType -trimprefix FormFieldType -transform=snake -json
type FormFieldType int
const (
FormFieldTypeTextSingle FormFieldType = iota // single line of text
FormFieldTypeTextMultiple // multiple lines of text
FormFieldTypeSingleSelect // single-select dropdown
FormFieldTypeMultiSelect // multi-select dropdown
)
// FieldLogicComparator enum enumerates all possible form field logic comparators
//
//go:generate enumer -type FieldLogicComparator -trimprefix FieldLogicComparator -transform=snake -json -text
type FieldLogicComparator int
const (
FieldLogicComparatorEqual FieldLogicComparator = iota // target field value is equal to the subject value
FieldLogicComparatorContains // target field value contains the subject value
)
// FieldLogicTriggerAction enum enumerates all possible field logic trigger actions
//
//go:generate enumer -type FieldLogicTriggerAction -trimprefix FieldLogicTriggerAction -transform=snake -json -text
type FieldLogicTriggerAction int
const (
FieldLogicTriggerShow FieldLogicTriggerAction = iota // show the field in the to the user
)
// FormFields is a collection of form fields associated with a Form
//
// The underlying type is a map, where keys are form field IDs and values are the corresponding form field
type FormFields map[string]FormField
// FieldOptions are options for single or multi-selector fields
type FieldOptions []Option
// FormField is a field associated with a form
type FormField struct {
ID uuid.UUID `json:"id"` // field's unique id
Order int `json:"order"` // order in which the field appears on forms
Label string `json:"label"` // field's label (name)
Logic *FieldLogic `json:"logic"` // UI logic for this field
Options FieldOptions `json:"options"` // single/multi-select options
Placeholder string `json:"placeholder"` // placeholder value
Required bool `json:"required"` // whether the field is required
Hidden bool `json:"hidden"` // whether the field is hidden
Type FormFieldType `json:"type"` // field type
}
// FieldLogic defines logic associated with a field
type FieldLogic struct {
TargetFieldID uuid.UUID `json:"target_field_id"` // ID of the field to monitor for logic evaluation
TriggerComparator FieldLogicComparator `json:"field_comparator"` // comparator to use evaluating target field's value with trigger values
TriggerValues []string `json:"trigger_values"` // values that target field's value is compared with
TriggerActions FieldLogicTriggerActions `json:"actions"` // actions to take when the field comparator evaluates true
}
type FieldLogicTriggerActions []FieldLogicTriggerAction
// Contains determines whether FieldLogicTriggerActions contains some other trigger action
func (f FieldLogicTriggerActions) Contains(a FieldLogicTriggerAction) bool {
for _, ta := range f {
if ta == a {
return true
}
}
return false
}
func (f *FieldLogic) Configured() bool {
return f.TargetFieldID != uuid.Nil && len(f.TriggerValues) > 0 && len(f.TriggerActions) > 0
}
// Option is a select option (single and multi)
type Option struct {
ID uuid.UUID `json:"id"`
Value string `json:"value"`
Label string `json:"label"`
Selected bool `json:"-"`
Disabled bool `json:"-"`
}
// FormFieldSortByOrder implements sort.Interface for []FormField based on
// the Order field.
type FormFieldSortByOrder []FormField
func (f FormFieldSortByOrder) Len() int { return len(f) }
func (f FormFieldSortByOrder) Swap(i, j int) { f[i], f[j] = f[j], f[i] }
func (f FormFieldSortByOrder) Less(i, j int) bool { return f[i].Order < f[j].Order }
// MarshalJSON implements the json.Marshaler interface for FormFieldType
func (f FormField) MarshalJSON() ([]byte, error) {
id := uuid.Nil
if f.ID != id {
id = f.ID
}
d := struct {
ID uuid.UUID `json:"id"` // field's unique id
Order int `json:"order"` // order in which the field appears on forms
Label string `json:"label"` // field's label (name)
Logic *FieldLogic `json:"logic"`
Options FieldOptions `json:"options"` // single/multi-select options
Placeholder string `json:"placeholder"` // placeholder value
Required bool `json:"required"` // whether the field is required
Hidden bool `json:"hidden"` // whether the field is hidden
Type FormFieldType `json:"type"` // field type
}{
ID: id,
Order: f.Order,
Label: f.Label,
Options: f.Options,
Placeholder: f.Placeholder,
Required: f.Required,
Hidden: f.Hidden,
Type: f.Type,
}
if f.Logic != nil && f.Logic.Configured() {
d.Logic = f.Logic
} else {
d.Logic = nil
}
return json.Marshal(d)
}

914
ui/common.templ Normal file
View file

@ -0,0 +1,914 @@
package ui
import (
"fmt"
"strings"
"context"
"github.com/acaloiaro/frm"
"github.com/acaloiaro/frm/ui/selector"
"github.com/acaloiaro/frm/handlers"
"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">
<!---->
<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(fmt.Sprintf("%s%s", ctx.Value(handlers.MountPointContextKey).(string), path))
}
// Path returns mountpoint-aware string paths for the given path
func Path(ctx context.Context, path string) string {
return fmt.Sprintf("%s%s", ctx.Value(handlers.MountPointContextKey).(string), 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={ Path(ctx, "/css/styles.css") } rel="stylesheet"/>
<script type="text/javascript" src={ Path(ctx, "/js/htmx.js") } nonce={ templ.GetNonce(ctx) }></script>
<script type="text/javascript" src={ Path(ctx, "/js/htmx-response-targets.js") } nonce={ templ.GetNonce(ctx) }></script>
<script type="text/javascript" src={ Path(ctx, "/js/hyperscript.js") } nonce={ templ.GetNonce(ctx) }></script>
<script ytpe="text/javascript" src={ Path(ctx, "/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>
<link rel="stylesheet" href={ Path(ctx, "/css/choices.min.css") } nonce={ templ.GetNonce(ctx) }/>
}
</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={ Path(ctx, fmt.Sprintf("/%d/fields/order", form.ID)) }
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={ Path(ctx, fmt.Sprintf("/%d/fields", form.ID)) }
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(Path(ctx, fmt.Sprintf("/%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())),
// "_": fmt.Sprintf("on click take .tab-active from <#configurator-tabs .tab-active/> then add .hidden to .active-form-fields-section then take .active-form-fields-section for #%s then remove .hidden from #%s", 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={ Path(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
}

2730
ui/common_templ.go Normal file

File diff suppressed because it is too large Load diff

178
ui/common_templ.txt Normal file
View file

@ -0,0 +1,178 @@
<svg data-src=\"
\" class=\"h-5 w-5\"></svg>
<button
class=\"
\">
</button>
<div
class=\"
\">
</div>
<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\">
</h3></div>
<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\">
</div></div>
<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\"><!----><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>
<head><title>
::
</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
<link href=\"
\" rel=\"stylesheet\"><script type=\"text/javascript\" src=\"
\" nonce=\"
\"></script> <script type=\"text/javascript\" src=\"
\" nonce=\"
\"></script> <script type=\"text/javascript\" src=\"
\" nonce=\"
\"></script> <script ytpe=\"text/javascript\" src=\"
\" nonce=\"
\"></script> <script type=\"text/javascript\" src=\"https://unpkg.com/external-svg-loader@latest/svg-loader.min.js\" nonce=\"
\" async></script> <script type=\"text/javascript\" src=\"https://unpkg.com/sortablejs@latest/Sortable.min.js\" nonce=\"
\"></script> <script type=\"text/javascript\">\n\t\t\t\thtmx.onLoad(function(content) {\n\t\t\t\t var sortables = content.querySelectorAll(\".sortable\");\n\t\t\t\t for (var i = 0; i < sortables.length; i++) {\n\t\t\t\t var sortable = sortables[i];\n\t\t\t\t var sortableInstance = new Sortable(sortable, {\n\t\t\t\t animation: 150,\n\t\t\t\t\t\t draggable: \".sortme\",\n\t\t\t\t onMove: function (evt) {\n\t\t\t\t return evt.related.className.indexOf('htmx-indicator') === -1;\n\t\t\t\t },\n\t\t\t\t onEnd: function (evt) {\n\t\t\t\t this.option(\"disabled\", true);\n\t\t\t\t }\n\t\t\t\t });\n\t\t\t\t // Re-enable sorting on the `htmx:afterSwap` event\n\t\t\t\t sortable.addEventListener(\"htmx:afterSwap\", function() {\n\t\t\t\t sortableInstance.option(\"disabled\", false);\n\t\t\t\t });\n\t\t\t\t }\n\t\t\t\t})\n\n\t\t\t\tfunction formValueChanged(fieldID, newValue) {\n\t\t\t\t\tvar formMetadata = JSON.parse(document.getElementById('form-metadata').getAttribute(\"data-data\"));\n\t\t\t\t\tif (formMetadata == null) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\t// collect the fields that have logic monitoring the changed field \n\t\t\t\t\tvar watchingFields = Object.values(formMetadata.form.fields).filter(function(field) {\n\t\t\t\t\t\treturn field.logic != null && fieldID === field.logic.target_field_id\n\t\t\t\t\t});\n\n\t\t\t\t\t// no fields watch the one that changed\n\t\t\t\t\tif (watchingFields.length == 0) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tvar fieldElement = document.getElementById(fieldID)\n\t\t\t\t\tfor (i in watchingFields) {\n\t\t\t\t\t\tlet watchingField = watchingFields[i]\n\t\t\t\t\t\tlet match = false\n\t\t\t\t\t\tvar watcherFieldContainerID = `field-container-${watchingField.id}` // the DOM element that contains the watching field\n\t\t\t\t\t\tlogic = watchingField.logic\n\n\t\t\t\t\t\t// check whether the new value is coming from a Choices.js field, in which case the new value\n\t\t\t\t\t\t// is the array of chosen values, joined by commas, otherwise newValue is used as it was passed in\n\t\t\t\t\t\tif (fieldElement != null && fieldElement._choices != null) {\n\t\t\t\t\t\t\t// Choics.getValue() returns scalar for single selects and array for multi. Use Array.of\n\t\t\t\t\t\t\t// to treat everything it returns as an array\n\t\t\t\t\t\t\tnewValue = Array.of(fieldElement._choices.getValue(true)).join(',')\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// find if _any_ trigger values match the new value\n\t\t\t\t\t\tswitch (logic.field_comparator) {\n\t\t\t\t\t\t\tcase 'equal':\n\t\t\t\t\t\t\t\tmatch = watchingField.logic.trigger_values.every(val => newValue.localeCompare(val, 'en', {sensitivity: \"base\"}) == 0)\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase 'contains':\n\t\t\t\t\t\t\t\tmatch = watchingField.logic.trigger_values.some(val => newValue.toLowerCase().includes(val.toLowerCase()))\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Most actions are likely to be performed upon the containing element, such as show/hide/require.\n\t\t\t\t\t\t// This may of course change or be expanded, but for now, only the watcher field container is relevant\n\t\t\t\t\t\t// to applying actions.\n\t\t\t\t\t\tlet el = document.getElementById(watcherFieldContainerID)\n\t\t\t\t\t\tlet actions = watchingField.logic.actions\n\n\t\t\t\t\t\t// we have a match, execute the action\n\t\t\t\t\t\tfor (i in actions) {\n\t\t\t\t\t\t\tswitch (actions[i]) {\n\t\t\t\t\t\t\t\tcase \"field_logic_trigger_show\":\n\t\t\t\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\t\t\t\tel.classList.remove(\"hidden\");\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\tel.classList.add(\"hidden\");\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t</script> <link rel=\"stylesheet\" href=\"
\" nonce=\"
\">
</head>
<!doctype html><html lang=\"eng\">
<body class=\"\"><div id=\"errors\"></div><main hx-ext=\"response-targets\">
</main>
</body></html>
<section id=\"app-container\">
<section id=\"builder-main\" class=\"flex w-full\"><!-- Left column -->
<!-- Middle Column -->
<!-- Right Column -->
</section></section>
<div id=\"settings-main\" class=\"hidden\"><form id=\"settings-form\" data-hx-put=\"
\" data-hx-trigger=\"
\" data-hx-swap-oob=\"true\"><div class=\"pt-3\"><label for=\"form-name-field\" class=\"pr-1\">Form name</label>
</div><input id=\"form-name-field\" name=\"name\" type=\"text\" class=\"w-full rounded-md\" value=\"
\" placeholder=\"Enter a good name for your form\" autocomplete=\"off\" _=\"
\"></form></div>
<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\">
</section>
<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\">
</div></div></section>
<div class=\"p-1 rounded-md flex items-center justify-center bg-blue-100 text-blue-900 ml-2\">
</div>
<div id=\"form-fields\" class=\"active-section\">
</div>
<div id=\"form-fields-form\" data-hx-swap-oob=\"true\" class=\"flex flex-col gap-3 w-full\">
<div class=\"w-full border-b pb-2\"></div><form data-hx-put=\"
\" 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\">
<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=\"
\"><div class=\"group flex items-center gap-x-0.5 py-1.5 pr-1\"><!-- field item --><a href=\"#\" class=\"w-full\" _=\"
\"><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\">
</p></div></div></a>
<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\">
</div></div></div>
</div></form></div>
<span class=\"text-red-500 required-dot\">*</span>
<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>
<div class=\"group flex items-center my-1.5 pr-1\"><a href=\"#\" class=\"w-full\"><div class=\"flex flex-col\"><div tabindex=\"
\" 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=\"
\" data-hx-trigger=\"click\" data-hx-vals=\"
\" data-hx-swap=\"none\">
</div></div></a></div>
</div>
</section>
<form id=\"fields-form\" data-hx-put=\"
\" data-hx-trigger=\"
\" data-hx-swap=\"none\" data-hx-swap-oob=\"true\">
<div id=\"
\" class=\"hidden\"><div class=\"mx-auto w-full border-gray-300 transition-colors pb-3\">
</div><div id=\"
\" 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\">
</div></div><!-- Form fields settings configurations -->
<!-- Form fields logic configurations -->
</div>
</form>
<div id=\"
\" class=\"active-configurator-section\"><input name=\"
\" type=\"hidden\" value=\"
\"> <input name=\"
\" type=\"hidden\" value=\"
\"> <input name=\"
\" type=\"hidden\" value=\"
\"><div class=\"pt-3\"><label for=\"
\" class=\"pr-1\">Field label</label>
</div><input id=\"
\" name=\"
\" type=\"text\" class=\"w-full rounded-md\" value=\"
\" autocomplete=\"off\" _=\"
\"><div class=\"pt-3\"><label for=\"
\" class=\"pr-1\">Placeholder</label></div><input id=\"
\" name=\"
\" type=\"text\" class=\"w-full rounded-md\" value=\"
\" autocomplete=\"off\" _=\"
\">
<div class=\"pt-3\"><label for=\"
\" class=\"pr-1\">Options</label></div>
<div class=\"pt-3\"><label for=\"
\" class=\"pr-1\">Required</label></div><input id=\"
\" name=\"
\" type=\"checkbox\" class=\"checkbox checkbox-primary\"
checked
_=\"
\"><div class=\"pt-3\"><label for=\"
\" class=\"pr-1\">Hidden</label></div><input id=\"
\" name=\"
\" type=\"checkbox\" class=\"checkbox checkbox-primary\"
checked
_=\"
\"><div class=\"py-3 divide-y\"><label for=\"delete-field\" class=\"pr-1\">Danger zone</label></div>
</div>
<div id=\"
\" class=\"flex flex-col gap-5 hidden\"><div><div data-hx-get=\"
\" data-hx-trigger=\"
\" data-hx-swap=\"innerHTML\" data-hx-target=\"
\" data-hx-on:htmx:config-request=\"event.detail.parameters[&#39;id&#39;] = event.detail.triggeringEvent.detail.value\">
</div></div><div>
</div><div><div id=\"
\">
</div></div><div><p class=\"pb-3\">Choose an action</p><input id=\"
\" type=\"checkbox\" class=\"checkbox\" name=\"
\"
checked
_=\"
\"> <label for=\"
\">Show this field</label></div></div>
<input id=\"
\" name=\"
\" type=\"text\" class=\"bg-gray-50\" placeholder=\"Enter a value\"
value=\"
\"
_=\"
\">
<div class=\"flex gap-3\">
<label class=\"w-full cursor-pointer truncate\">
Single-line text
Multi-line text
Single select
Multi select
<label class=\"w-full cursor-pointer truncate\">I unno</label>
</label></div>
<label for=\"
\">
</label>
<div id=\"form-viewer\" class=\"flex flex-col w-full justify-top\" data-hx-swap-oob=\"true\"><div id=\"form-metadata\" data-data=\"
\"></div><h1 class=\"hover:bg-gray-50 dark:hover:bg-gray-800 rounded cursor-pointer relative mb-2 font-black text-5xl\">
</h1><form action=\"
\" _=\"on field_change(field_id, value) formValueChanged(field_id, value)\">
<div id=\"
\"
class=\"flex flex-col py-3 hidden\"
class=\"flex flex-col py-3\"
>
<input id=\"
\" name=\"
\" placeholder=\"
\" type=\"text\" autocomplete=\"off\" _=\"
\">
<textarea id=\"
\" name=\"
\" 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=\"
\" autocomplete=\"off\" style=\"--tw-ring-color: #3B82F6;\" _=\"
\"></textarea>
</div>
<div class=\"py-3\"></div>
</form></div>

After

Width:  |  Height:  |  Size: 13 KiB

10
ui/css/tailwind.css Normal file
View file

@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
globally kill the tailwind ring by default
*/
input {
--tw-ring-shadow: 0 0 #000 !important;
}

238
ui/selector/selector.templ Normal file
View file

@ -0,0 +1,238 @@
// package selector provides a single and multi-slect HTML element powered by Choices.js
package selector
import (
"fmt"
"encoding/json"
"github.com/google/uuid"
)
type FieldOptions []Option
// Option is a select option
type Option struct {
ID uuid.UUID `json:"id"`
Value string `json:"value"`
Label string `json:"label"`
Selected bool `json:"selected"`
Disabled bool `json:"disabled"`
}
// ContentID is the HTML element ID where content for this option is rendered when OptionsContent is rendered
func (i Option) ContentID() string {
return fmt.Sprintf("option-%s-content", i.ID)
}
// SelectArgs are the arguments used to initialize selector.Selector
//
// ID: element ID for the <select> element
//
// Label: The <label> for the <select> element
//
// LabelClass: The CSS class(es) for the <label> element
//
// Multiple: Allow multiple items to be selected
//
// Name: The <select> form element name
//
// Options: The <option>s available to be <select>ed. Use either Options or OptionsContent to provide options to selector.Seletor
//
// OptionsContent: The <option>s available to be <select>ed, and the content to be conditionally rendered when option is selected. Use either Options or OptionsContent, but not both.
//
// Placeholder: Placeholder text shown in the search box
//
// SearchDisabled: Disable the ability to search for items
//
// EditItems: Allow new items to be created through the ui
//
// SelectionChangeEvent: The name of the DOM event to trigger when selections change. This event bubbles. It is triggered on the selector element, which does not handle it. An element up the DOM tree should handle it.
//
// Hyperscript: Attach a hyperscript _ attribute to the <select>.
type SelectArgs struct {
ID string
Label string
LabelClass string
Multiple bool
Name string
Options FieldOptions
OptionsContent map[Option]templ.Component
Placeholder string
Required bool
SearchDisabled bool
EditItems bool
SelectionChangeEvent string
Hyperscript string
}
// MarhsahJSON allows SelectArgs to be serialized to JSON, to be added as a `data-*` field for latest access
func (a SelectArgs) MarshalJSON() (b []byte, err error) {
d := struct {
ID string
Name string
Label string
LabelClass string
Options []Option
OptionsContent map[string]string // mapping of opton ID to the ID of the HTML element where content should render when selected
Placeholder string
Multiple bool
SearchDisabled bool
EditItems bool
SelectionChangeEvent string
Hyperscript string
}{
ID: a.ID,
Name: a.Name,
Label: a.Label,
LabelClass: a.LabelClass,
Options: a.Options,
OptionsContent: map[string]string{},
Placeholder: a.Placeholder,
Multiple: a.Multiple,
SearchDisabled: a.SearchDisabled,
EditItems: a.EditItems,
SelectionChangeEvent: a.SelectionChangeEvent,
Hyperscript: a.Hyperscript,
}
if len(a.OptionsContent) > 0 {
for option := range a.OptionsContent {
d.Options = append(d.Options, option)
d.OptionsContent[option.ID.String()] = option.ContentID()
}
}
b, err = json.Marshal(d)
if err != nil {
return nil, err
}
return
}
// dataElementID is the element ID of the HTML element that contains the JSON-encoded data for 'args'
func dataElementID(args SelectArgs) string {
return fmt.Sprintf("selector-data-%s", args.ID)
}
// Selector converts a standard 'select' element into a Choices.js-enriched select element
//
// To use seletor as a multi-selet, the select element must have a the 'multiple' attribute, e.g.
// <select id="my-id" multiple></select>
templ Selector(args SelectArgs) {
if args.Label != "" {
<label for={ args.ID } class={ args.LabelClass }>
{ args.Label }
if args.Required {
<span class="text-red-500 required-dot">*</span>
}
</label>
}
<select
id={ args.ID }
name={ args.Name }
if args.Multiple {
multiple
}
if args.Required {
required="true"
}
data-placeholder={ args.Placeholder }
if args.Hyperscript != "" {
_={ args.Hyperscript }
}
></select>
<div id={ dataElementID(args) } data-args={ templ.JSONString(args) } _={ fmt.Sprintf("init initializeSelect('%s')", dataElementID(args)) }></div>
<!-- when OptionsContent
are present, their components render inside of this element when chosen -->
if len(args.OptionsContent) > 0 {
<!-- content displayed when specific options are selected -->
<div id="selected-content">
for item, component := range args.OptionsContent {
<div id={ item.ContentID() } class="hidden">
@component
</div>
}
</div>
}
<script>
/**
* Function to wait for predicates.
* @param {function() : Promise.<Boolean> | function() : Boolean} predicate
* - A function that returns or resolves a bool
* @param {Number} [timeout] - Optional maximum waiting time in ms after rejected
*/
function waitFor(predicate, timeout) {
return new Promise((resolve, reject) => {
let running = true;
const check = async () => {
const res = await predicate();
if (res) return resolve(res);
if (running) setTimeout(check, 100);
};
check();
if (!timeout) return;
setTimeout(() => {
running = false;
reject();
}, timeout);
});
}
async function initializeSelect(dataElementID) {
// wait 10 seconds for choices.js to load
try {
await waitFor(() => typeof Choices !== 'undefined', 10000);
} catch {
console.log("unable to load choices!")
}
const argsElement = document.getElementById(dataElementID);
const args = JSON.parse(argsElement.getAttribute('data-args'));
if (args.Options != null) {
args.Options.map((option) => {
if (option.value === "") {
console.error(`option id: (${option.id}) label: (${option.label}) should have a value! ensure every option provided to SelectorArgs has a 'Value'`)
}
});
}
var searchEnabled = true
if (args.SearchDisabled) {
searchEnabled = false
}
var element = document.getElementById(args.ID)
var choices = new Choices(element, {
choices: args.Options != null ? args.Options : [],
duplicateItemsAllowed: false,
placeholder: true,
placeholderValue: args.Placeholder,
searchEnabled: searchEnabled,
editItems: args.EditItems,
removeItemButton: true,
addChoices: true,
addItems: true,
itemSelectText: "",
});
element._choices = choices;
/**
* When the user's selection(s) have changed, notify a DOM element with an event
*/
function notifySelected(event) {
if (args.SelectionChangeEvent == "") {
return
}
var element = document.getElementById(args.ID)
if (element == null) {
return
}
element.dispatchEvent(new CustomEvent(args.SelectionChangeEvent, {bubbles: true, detail: { id: args.ID, value: event.detail.value}}))
}
element.addEventListener('change', notifySelected, false);
}
</script>
}

View file

@ -0,0 +1,377 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.793
// package selector provides a single and multi-slect HTML element powered by Choices.js
package selector
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"encoding/json"
"fmt"
"github.com/google/uuid"
)
type FieldOptions []Option
// Option is a select option
type Option struct {
ID uuid.UUID `json:"id"`
Value string `json:"value"`
Label string `json:"label"`
Selected bool `json:"selected"`
Disabled bool `json:"disabled"`
}
// ContentID is the HTML element ID where content for this option is rendered when OptionsContent is rendered
func (i Option) ContentID() string {
return fmt.Sprintf("option-%s-content", i.ID)
}
// SelectArgs are the arguments used to initialize selector.Selector
//
// ID: element ID for the <select> element
//
// Label: The <label> for the <select> element
//
// LabelClass: The CSS class(es) for the <label> element
//
// Multiple: Allow multiple items to be selected
//
// Name: The <select> form element name
//
// Options: The <option>s available to be <select>ed. Use either Options or OptionsContent to provide options to selector.Seletor
//
// OptionsContent: The <option>s available to be <select>ed, and the content to be conditionally rendered when option is selected. Use either Options or OptionsContent, but not both.
//
// Placeholder: Placeholder text shown in the search box
//
// SearchDisabled: Disable the ability to search for items
//
// EditItems: Allow new items to be created through the ui
//
// SelectionChangeEvent: The name of the DOM event to trigger when selections change. This event bubbles. It is triggered on the selector element, which does not handle it. An element up the DOM tree should handle it.
//
// Hyperscript: Attach a hyperscript _ attribute to the <select>.
type SelectArgs struct {
ID string
Label string
LabelClass string
Multiple bool
Name string
Options FieldOptions
OptionsContent map[Option]templ.Component
Placeholder string
Required bool
SearchDisabled bool
EditItems bool
SelectionChangeEvent string
Hyperscript string
}
// MarhsahJSON allows SelectArgs to be serialized to JSON, to be added as a `data-*` field for latest access
func (a SelectArgs) MarshalJSON() (b []byte, err error) {
d := struct {
ID string
Name string
Label string
LabelClass string
Options []Option
OptionsContent map[string]string // mapping of opton ID to the ID of the HTML element where content should render when selected
Placeholder string
Multiple bool
SearchDisabled bool
EditItems bool
SelectionChangeEvent string
Hyperscript string
}{
ID: a.ID,
Name: a.Name,
Label: a.Label,
LabelClass: a.LabelClass,
Options: a.Options,
OptionsContent: map[string]string{},
Placeholder: a.Placeholder,
Multiple: a.Multiple,
SearchDisabled: a.SearchDisabled,
EditItems: a.EditItems,
SelectionChangeEvent: a.SelectionChangeEvent,
Hyperscript: a.Hyperscript,
}
if len(a.OptionsContent) > 0 {
for option := range a.OptionsContent {
d.Options = append(d.Options, option)
d.OptionsContent[option.ID.String()] = option.ContentID()
}
}
b, err = json.Marshal(d)
if err != nil {
return nil, err
}
return
}
// dataElementID is the element ID of the HTML element that contains the JSON-encoded data for 'args'
func dataElementID(args SelectArgs) string {
return fmt.Sprintf("selector-data-%s", args.ID)
}
// Selector converts a standard 'select' element into a Choices.js-enriched select element
//
// To use seletor as a multi-selet, the select element must have a the 'multiple' attribute, e.g.
// <select id="my-id" multiple></select>
func Selector(args SelectArgs) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if args.Label != "" {
var templ_7745c5c3_Var2 = []any{args.LabelClass}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(args.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 123, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(args.Label)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 124, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if args.Required {
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(args.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 131, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(args.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 132, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if args.Multiple {
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if args.Required {
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(args.Placeholder)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 139, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if args.Hyperscript != "" {
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(args.Hyperscript)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 141, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(dataElementID(args))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 144, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(templ.JSONString(args))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 144, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("init initializeSelect('%s')", dataElementID(args)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 144, Col: 137}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(args.OptionsContent) > 0 {
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for item, component := range args.OptionsContent {
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.ContentID())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/selector/selector.templ`, Line: 151, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = component.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return templ_7745c5c3_Err
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,25 @@
<label for=\"
\" class=\"
\">
<span class=\"text-red-500 required-dot\">*</span>
</label>
<select id=\"
\" name=\"
\"
multiple
required=\"true\"
data-placeholder=\"
\"
_=\"
\"
></select><div id=\"
\" data-args=\"
\" _=\"
\"></div><!-- when OptionsContent\n are present, their components render inside of this element when chosen -->
<!-- content displayed when specific options are selected --> <div id=\"selected-content\">
<div id=\"
\" class=\"hidden\">
</div>
</div>
<script>\n\t\t/**\n\t\t * Function to wait for predicates.\n\t\t * @param {function() : Promise.<Boolean> | function() : Boolean} predicate\n\t\t * - A function that returns or resolves a bool\n\t\t * @param {Number} [timeout] - Optional maximum waiting time in ms after rejected\n\t\t */\n\t\tfunction waitFor(predicate, timeout) {\n\t\t return new Promise((resolve, reject) => {\n\t\t let running = true;\n\n\t\t const check = async () => {\n\t\t const res = await predicate();\n\t\t if (res) return resolve(res);\n\t\t if (running) setTimeout(check, 100);\n\t\t };\n\n\t\t check();\n\n\t\t if (!timeout) return;\n\t\t setTimeout(() => {\n\t\t running = false;\n\t\t reject();\n\t\t }, timeout);\n\t\t });\n\t\t}\n\n\t\tasync function initializeSelect(dataElementID) {\n\t\t\t// wait 10 seconds for choices.js to load \n\t\t\ttry {\n\t\t\t\tawait waitFor(() => typeof Choices !== 'undefined', 10000);\n\t\t\t} catch {\n\t\t\t\tconsole.log(\"unable to load choices!\")\n\t\t\t}\n\n\t\t\tconst argsElement = document.getElementById(dataElementID);\n\t\t\tconst args = JSON.parse(argsElement.getAttribute('data-args'));\n\t\t\tif (args.Options != null) {\n\t\t\t\targs.Options.map((option) => {\n\t\t\t\t\tif (option.value === \"\") {\n\t\t\t\t\t\tconsole.error(`option id: (${option.id}) label: (${option.label}) should have a value! ensure every option provided to SelectorArgs has a 'Value'`)\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t\tvar searchEnabled = true\n\t\t\tif (args.SearchDisabled) {\n\t\t\t\tsearchEnabled = false\n\t\t\t}\n\n\t\t\tvar element = document.getElementById(args.ID)\n\t\t\tvar choices = new Choices(element, {\n\t\t\t\tchoices: args.Options != null ? args.Options : [],\n\t\t\t\tduplicateItemsAllowed: false,\n\t\t\t\tplaceholder: true,\n\t\t\t\tplaceholderValue: args.Placeholder,\n\t\t\t\tsearchEnabled: searchEnabled,\n\t\t\t\teditItems: args.EditItems,\n\t\t\t\tremoveItemButton: true,\n\t\t\t\taddChoices: true,\n\t\t\t\taddItems: true,\n\t\t\t\titemSelectText: \"\",\n\t\t\t});\n\t\t\telement._choices = choices;\n\t\t\n\t\t\t/**\n\t\t\t* When the user's selection(s) have changed, notify a DOM element with an event\n\t\t\t*/\n\t\t\tfunction notifySelected(event) {\n\t\t\t\tif (args.SelectionChangeEvent == \"\") {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tvar element = document.getElementById(args.ID)\n\t\t\t\tif (element == null) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\telement.dispatchEvent(new CustomEvent(args.SelectionChangeEvent, {bubbles: true, detail: { id: args.ID, value: event.detail.value}}))\n\t\t\t}\n\t\t\t\n\t\t\telement.addEventListener('change', notifySelected, false);\n\t\t}\n\t</script>

86
ui/tailwind.config.js Normal file
View file

@ -0,0 +1,86 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'ui/**/*.templ',
],
darkMode: 'class',
theme: {
extend: {
colors: {
/*
Below color shades come from https://uicolors.app/create
From the PastSight branding kit
Primary original shade: #00255A
Secondary original shade: 00A583
*/
transparent: 'transparent',
current: 'currentColor',
'white': '#ffffff',
'red': {
'100': '#ffeeee',
'200': '#ffdddd',
'300': '#ffbbbb',
'400': '#ff9999',
'500': '#ff7777',
'600': '#ff5555',
'700': '#ff3333',
'800': '#ff1111',
'900': '#ee0000'
},
'primary': {
'50': '#e9f9ff',
'100': '#cef1ff',
'200': '#a7e8ff',
'300': '#6bdeff',
'400': '#26c6ff',
'500': '#009fff',
'600': '#0075ff',
'700': '#005aff',
'800': '#004de6',
'900': '#0047b3',
'950': '#00255a',
},
'secondary': {
'50': '#ebfef7',
'100': '#d0fbe9',
'200': '#a4f6d9',
'300': '#6aebc5',
'400': '#2fd8ac',
'500': '#0abf96',
'600': '#00a583',
'700': '#007c66',
'800': '#036251',
'900': '#045044',
'950': '#012d28',
},
'gray': {
'50': '#f6f6f6',
'100': '#e7e7e7',
'200': '#d1d1d1',
'300': '#b0b0b0',
'400': '#888888',
'500': '#6d6d6d',
'600': '#5d5d5d',
'700': '#4c4c4c',
'800': '#454545',
'900': '#3d3d3d',
'950': '#262626',
},
},
fontFamily: {
sans: ['Helvetica', 'Arial', 'sans-serif'],
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
require('daisyui'),
],
corePlugins: {
preflight: true,
}
}

10
utils/default.go Normal file
View file

@ -0,0 +1,10 @@
package utils
// SafelyDereference safely dereferences pointer by using a default when the pointer is nil
func SafelyDereference[T any](pointer *T, defaultValue T) T {
if pointer != nil {
return *pointer
} else {
return defaultValue
}
}