mirror of
https://github.com/acaloiaro/frm
synced 2026-07-21 10:12:23 +00:00
initial commit
This commit is contained in:
commit
caa95c66b6
46 changed files with 12151 additions and 0 deletions
45
.air.toml
Normal file
45
.air.toml
Normal 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
15
.envrc
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.direnv
|
||||||
|
.devenv
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.env
|
||||||
|
tmp
|
||||||
13
README.md
Normal file
13
README.md
Normal 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
84
cmd/dev_server/main.go
Normal 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
91
db/create_forms_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE forms;
|
||||||
|
DROP SEQUENCE IF EXISTS forms_ids;
|
||||||
20
db/migrations/20241105222317_add_forms_table.sql.up.sql
Normal file
20
db/migrations/20241105222317_add_forms_table.sql.up.sql
Normal 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
6
db/migrations/default.go
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var FS embed.FS
|
||||||
19
db/queries.sql
Normal file
19
db/queries.sql
Normal 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
0
env.sample
Normal file
2252
flake.lock
Normal file
2252
flake.lock
Normal file
File diff suppressed because it is too large
Load diff
209
flake.nix
Normal file
209
flake.nix
Normal 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
41
frm.go
Normal 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
6
frmchi/package.go
Normal 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
525
frmchi/router.go
Normal 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
25
go.mod
Normal 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
99
go.sum
Normal 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
51
gomod2nix.toml
Normal 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
25
handlers/handlers.go
Normal 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
32
internal/db.go
Normal 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
148
internal/default.go
Normal 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
24
internal/models.go
Normal 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
113
internal/queries.sql.go
Normal 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
53
sqlc.yaml
Normal 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
6
static/assets.go
Normal 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
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
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
2
static/js/choices.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
130
static/js/htmx-response-targets.js
Normal file
130
static/js/htmx-response-targets.js
Normal 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
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
1
static/js/hyperscript.js
Normal file
File diff suppressed because one or more lines are too long
20
types.go
Normal file
20
types.go
Normal 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)
|
||||||
|
}
|
||||||
108
types/fieldlogiccomparator_enumer.go
Normal file
108
types/fieldlogiccomparator_enumer.go
Normal 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
|
||||||
|
}
|
||||||
104
types/fieldlogictriggeraction_enumer.go
Normal file
104
types/fieldlogictriggeraction_enumer.go
Normal 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
|
||||||
|
}
|
||||||
104
types/formfieldtype_enumer.go
Normal file
104
types/formfieldtype_enumer.go
Normal 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
138
types/types.go
Normal 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
914
ui/common.templ
Normal 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
2730
ui/common_templ.go
Normal file
File diff suppressed because it is too large
Load diff
178
ui/common_templ.txt
Normal file
178
ui/common_templ.txt
Normal 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['id'] = 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
10
ui/css/tailwind.css
Normal 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
238
ui/selector/selector.templ
Normal 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>
|
||||||
|
}
|
||||||
377
ui/selector/selector_templ.go
Normal file
377
ui/selector/selector_templ.go
Normal 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
|
||||||
25
ui/selector/selector_templ.txt
Normal file
25
ui/selector/selector_templ.txt
Normal 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
86
ui/tailwind.config.js
Normal 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
10
utils/default.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue