feat: add form clone support

This commit is contained in:
Adriano Caloiaro 2025-02-11 14:54:17 -07:00
parent ff10a77d72
commit 62afa56afd
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
10 changed files with 205 additions and 53 deletions

View file

@ -77,7 +77,7 @@ func TestCreateAndUpdateForm(t *testing.T) {
t.Error(err)
return
}
f, err := internal.Q(ctx, frms.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{
f, err := internal.Q(ctx, frms.DBArgs).SaveForm(ctx, internal.SaveFormParams{
Name: "hello world",
Fields: fields,
WorkspaceID: frms.WorkspaceID,
@ -87,7 +87,7 @@ func TestCreateAndUpdateForm(t *testing.T) {
return
}
f, err = internal.Q(ctx, frms.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{
f, err = internal.Q(ctx, frms.DBArgs).SaveForm(ctx, internal.SaveFormParams{
ID: f.ID,
Name: nameUpdate,
Fields: updatedFields,

View file

@ -0,0 +1 @@
ALTER TYPE form_status DROP VALUE 'archived';

View file

@ -0,0 +1 @@
ALTER TYPE form_status ADD VALUE IF NOT EXISTS 'archived';

View file

@ -38,14 +38,15 @@ WHERE workspace_id = @workspace_id
AND form_id = @form_id
AND status = 'draft';
-- name: SaveDraft :one
-- name: SaveForm :one
INSERT INTO forms (id, form_id, workspace_id, name, fields)
VALUES (coalesce(nullif(@id, 0), nextval('form_ids'))::bigint, @form_id, @workspace_id, @name, @fields) ON conflict(id) DO
INSERT INTO forms (id, form_id, workspace_id, name, fields, status)
VALUES (coalesce(nullif(@id, 0), nextval('form_ids'))::bigint, @form_id, @workspace_id, @name, @fields, coalesce(nullif(@status, ''), 'draft')::form_status) ON conflict(id) DO
UPDATE
SET updated_at = timezone('utc', now()),
name = @name,
fields = @fields RETURNING *;
status = coalesce(nullif(@status, '')::form_status, forms.status),
fields = coalesce(@fields, forms.fields) RETURNING *;
-- name: PublishDraft :one
WITH draft AS

45
frm.go
View file

@ -12,6 +12,7 @@ import (
const (
EventDraftCreated = "frmDraftCreated" // htmx event sent when new drafts are created
DefaultCopyNameSuffix = "(COPY)" // the default suffix added to forms when they're copied
)
var ErrCannotDetermineWorkspace = errors.New("workspace cannot be determine without WorkspaceID or WorkspaceIDUrlParam")
@ -49,10 +50,13 @@ type FormSubmissionReceiver = func(ctx context.Context, submission FormSubmissio
// - Published forms are available to be used
//
// - Draft forms are in a draft state, yet to be published
//
// - Archived forms are not intended to be used
type FormStatus = internal.FormStatus
const FormStatusPublished = internal.FormStatusPublished
const FormStatusDraft = internal.FormStatusDraft
const FormStatusArchived = internal.FormStatusArchived
// New initializes a new frm instance
//
@ -98,6 +102,47 @@ func (f *Frm) GetForm(ctx context.Context, id int64) (form Form, err error) {
return
}
// CopyFormArgs are passed to frm.CopyForm()
type CopyFormArgs struct {
ForgetParentForm bool // forget the parent form from which the copied form is copied
ID int64 // the id of the form to copy
NameSuffix string // suffix to be added to the original form's name, e.g. "(COPY)"
}
// CopyForm copies existing Forms
//
// Returns the copied form
func (f *Frm) CopyForm(ctx context.Context, args CopyFormArgs) (form Form, err error) {
var of internal.Form
of, err = internal.Q(ctx, f.DBArgs).GetForm(ctx, internal.GetFormParams{
WorkspaceID: f.WorkspaceID,
ID: args.ID,
})
if err != nil {
return
}
copiedFormName := of.Name
if args.NameSuffix != "" {
copiedFormName = fmt.Sprintf("%s %s", of.Name, args.NameSuffix)
}
p := &internal.SaveFormParams{
WorkspaceID: of.WorkspaceID,
FormID: &of.ID,
Name: copiedFormName,
Fields: of.Fields,
}
if args.ForgetParentForm {
p.FormID = nil
}
nf, err := internal.Q(ctx, f.DBArgs).SaveForm(ctx, *p)
if err != nil {
return
}
form = (Form)(nf)
return
}
// GetFormSubmission retrieves form submissions by ID
func (f *Frm) GetFormSubmission(ctx context.Context, submissionID int64) (sub FormSubmission, err error) {
var s internal.FormSubmission

View file

@ -2,12 +2,14 @@ package frm_test
import (
"context"
"fmt"
"os"
"testing"
"github.com/acaloiaro/frm"
"github.com/acaloiaro/frm/internal"
"github.com/acaloiaro/frm/types"
"github.com/google/uuid"
)
func TestCreateShortCode(t *testing.T) {
@ -27,7 +29,7 @@ func TestCreateShortCode(t *testing.T) {
URL: os.Getenv("POSTGRES_URL"),
DisableSSL: true,
Schema: "frm_test",
}).SaveDraft(ctx, internal.SaveDraftParams{
}).SaveForm(ctx, internal.SaveFormParams{
Name: "hello world",
Fields: types.FormFields{},
WorkspaceID: "1",
@ -61,3 +63,57 @@ func TestCreateShortCode(t *testing.T) {
t.Error("successive SaveShortCode calls should create same short code")
}
}
func TestCopyForm(t *testing.T) {
copiedFormNameSuffix := "(COPY)"
ctx := context.Background()
f, err := frm.New(frm.Args{
PostgresURL: os.Getenv("POSTGRES_URL"),
PostgresDisableSSL: true,
WorkspaceID: "1",
WorkspaceIDUrlParam: "client_id",
PostgresSchema: "frm_test",
})
if err != nil {
t.Error(err)
}
draft, err := internal.Q(ctx, internal.DBArgs{
URL: os.Getenv("POSTGRES_URL"),
DisableSSL: true,
Schema: "frm_test",
}).SaveForm(ctx, internal.SaveFormParams{
Name: "hello world",
Fields: types.FormFields{
uuid.New().String(): {
Label: "What's your name?",
Order: 0,
Required: true,
Type: types.FormFieldTypeTextSingle,
},
},
WorkspaceID: "1",
})
if err != nil {
t.Error(err)
return
}
clonedForm, err := f.CopyForm(ctx, frm.CopyFormArgs{
ID: draft.ID,
NameSuffix: copiedFormNameSuffix,
})
if err != nil {
t.Error(err)
return
}
expectedCopiedFormName := fmt.Sprintf("%s %s", draft.Name, copiedFormNameSuffix)
if clonedForm.Name != expectedCopiedFormName {
t.Error(fmt.Errorf("form name should be '%s'", draft.Name), "got:", clonedForm.Name)
return
}
if len(clonedForm.Fields) != len(draft.Fields) {
t.Error("cloned form should have the same number of fields as the original, but does not.", "actual number of fields:", len(clonedForm.Fields), "expected number of fields:", len(draft.Fields))
return
}
}

View file

@ -120,7 +120,7 @@ func UpdateFieldOrder(w http.ResponseWriter, r *http.Request) {
}
draft.Fields = updatedFields
draft, err = internal.Q(ctx, f.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{
draft, err = internal.Q(ctx, f.DBArgs).SaveForm(ctx, internal.SaveFormParams{
ID: draft.ID,
Name: draft.Name,
Fields: updatedFields,
@ -236,7 +236,7 @@ func UpdateSettings(w http.ResponseWriter, r *http.Request) {
if len(formName) == 0 {
formName = "New form"
}
form, err = internal.Q(ctx, f.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{
form, err = internal.Q(ctx, f.DBArgs).SaveForm(ctx, internal.SaveFormParams{
ID: draftID,
Name: formName,
Fields: form.Fields,
@ -339,7 +339,7 @@ func NewField(w http.ResponseWriter, r *http.Request) {
}
fields[fieldID.String()] = *newField
_, err = internal.Q(ctx, f.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{
_, err = internal.Q(ctx, f.DBArgs).SaveForm(ctx, internal.SaveFormParams{
ID: formID,
Name: draft.Name,
Fields: fields,
@ -489,7 +489,7 @@ func UpdateFields(w http.ResponseWriter, r *http.Request) {
}
draft.Fields = updatedFields
draft, err = internal.Q(ctx, f.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{
draft, err = internal.Q(ctx, f.DBArgs).SaveForm(ctx, internal.SaveFormParams{
ID: draft.ID,
Name: draft.Name,
Fields: updatedFields,
@ -544,7 +544,7 @@ func DeleteField(w http.ResponseWriter, r *http.Request) {
updatedFields := draft.Fields
delete(updatedFields, fmt.Sprint(fieldID))
draft.Fields = updatedFields
draft, err = internal.Q(ctx, f.DBArgs).SaveDraft(ctx, internal.SaveDraftParams{
draft, err = internal.Q(ctx, f.DBArgs).SaveForm(ctx, internal.SaveFormParams{
ID: draft.ID,
Name: draft.Name,
Fields: updatedFields,
@ -575,7 +575,59 @@ func DeleteField(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// NewDraft creates a new draft from scratch or an existing form
// ChangeStatus changes the status of forms
func ChangeStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
f, err := frm.Instance(ctx)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
formID, err := formID(ctx, f)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
err = r.ParseForm()
if err != nil {
slog.Error("unable to change form status", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
form, err := internal.Q(ctx, f.DBArgs).GetForm(ctx, internal.GetFormParams{
WorkspaceID: f.WorkspaceID,
ID: *formID,
})
if err != nil {
slog.Error("unable to get form", slog.Any("error", err), slog.Any("workspace_id", f.WorkspaceID), slog.Any("form_id", *formID))
w.WriteHeader(http.StatusInternalServerError)
return
}
status := frm.FormStatus(r.FormValue("status"))
form, err = internal.Q(ctx, f.DBArgs).SaveForm(ctx, internal.SaveFormParams{
WorkspaceID: f.WorkspaceID,
ID: form.ID,
Name: form.Name,
Fields: form.Fields,
Status: status,
})
if err != nil {
slog.Error("unable to change form status", slog.Any("error", err))
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
}
// NewDraft creates new drafts
//
// Drafts may be created from scratch by not providing a formID in the request
// Drafts may be created from existing forms by providing a formID in the request
// Drafts may be "clones" of existing forms by providing the "clone" URL parameter, the only differences between clones
// and drafts created from existing forms is that cloned form names are suffixed, and don't recall their parent form.
func NewDraft(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
f, err := frm.Instance(ctx)
@ -585,39 +637,39 @@ func NewDraft(w http.ResponseWriter, r *http.Request) {
return
}
isClone := r.URL.Query().Get("clone") != ""
suffix := r.URL.Query().Get("name_suffix")
var draft internal.Form
// dont check for errors here, because this endpoint handles both new drafts and drafts from existing forms
formID, _ := formID(ctx, f)
q := internal.Q(ctx, f.DBArgs)
draftParams := &internal.SaveDraftParams{
draftParams := &internal.SaveFormParams{
WorkspaceID: f.WorkspaceID,
Fields: types.FormFields{},
}
if formID != nil {
form, err := q.GetForm(ctx, internal.GetFormParams{
WorkspaceID: f.WorkspaceID,
copy, err := f.CopyForm(ctx, frm.CopyFormArgs{
ID: *formID,
ForgetParentForm: isClone,
NameSuffix: suffix,
})
if err != nil && errors.Is(err, pgx.ErrNoRows) {
w.WriteHeader(http.StatusNotFound)
} else if err != nil {
slog.Error("unable to create draft", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
draftParams.FormID = &form.ID
draftParams.Name = form.Name
draftParams.Fields = form.Fields
} else {
draftParams.Name = "New form"
}
draft, err := q.SaveDraft(ctx, *draftParams)
if err != nil {
slog.Error("unable to create draft", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
draft = (internal.Form)(copy)
} else {
draftParams.Name = "New form"
draft, err = q.SaveForm(ctx, *draftParams)
if err != nil {
slog.Error("unable to create draft", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
w.Header().Add("HX-Trigger", fmt.Sprintf("{\"%s\": {\"draft_id\": \"%d\"}}", frm.EventDraftCreated, draft.ID))
w.WriteHeader(http.StatusCreated)
@ -639,17 +691,7 @@ func PublishDraft(w http.ResponseWriter, r *http.Request) {
return
}
draft, err := internal.Q(ctx, f.DBArgs).GetDraft(ctx, internal.GetDraftParams{
WorkspaceID: f.WorkspaceID,
ID: *draftID,
})
if err != nil {
slog.Error("unable to publish draft", "error", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
_, err = internal.Q(ctx, f.DBArgs).PublishDraft(ctx, draft.ID)
_, err = internal.Q(ctx, f.DBArgs).PublishDraft(ctx, *draftID)
if err != nil {
slog.Error("unable to publish draft", "error", err)
w.WriteHeader(http.StatusInternalServerError)

View file

@ -17,6 +17,7 @@ type FormStatus string
const (
FormStatusPublished FormStatus = "published"
FormStatusDraft FormStatus = "draft"
FormStatusArchived FormStatus = "archived"
)
func (e *FormStatus) Scan(src interface{}) error {

View file

@ -355,39 +355,43 @@ func (q *Queries) PublishDraft(ctx context.Context, id int64) (Form, error) {
return i, err
}
const saveDraft = `-- name: SaveDraft :one
const saveForm = `-- name: SaveForm :one
INSERT INTO forms (id, form_id, workspace_id, name, fields)
VALUES (coalesce(nullif($1, 0), nextval('form_ids'))::bigint, $2, $3, $4, $5) ON conflict(id) DO
INSERT INTO forms (id, form_id, workspace_id, name, fields, status)
VALUES (coalesce(nullif($1, 0), nextval('form_ids'))::bigint, $2, $3, $4, $5, coalesce(nullif($6, ''), 'draft')::form_status) ON conflict(id) DO
UPDATE
SET updated_at = timezone('utc', now()),
name = $4,
fields = $5 RETURNING id, form_id, workspace_id, name, fields, status, created_at, updated_at
status = coalesce(nullif($6, '')::form_status, forms.status),
fields = coalesce($5, forms.fields) RETURNING id, form_id, workspace_id, name, fields, status, created_at, updated_at
`
type SaveDraftParams struct {
type SaveFormParams struct {
ID interface{} `json:"id"`
FormID *int64 `json:"form_id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Fields types.FormFields `json:"fields"`
Status interface{} `json:"status"`
}
// SaveDraft
// SaveForm
//
// INSERT INTO forms (id, form_id, workspace_id, name, fields)
// VALUES (coalesce(nullif($1, 0), nextval('form_ids'))::bigint, $2, $3, $4, $5) ON conflict(id) DO
// INSERT INTO forms (id, form_id, workspace_id, name, fields, status)
// VALUES (coalesce(nullif($1, 0), nextval('form_ids'))::bigint, $2, $3, $4, $5, coalesce(nullif($6, ''), 'draft')::form_status) ON conflict(id) DO
// UPDATE
// SET updated_at = timezone('utc', now()),
// name = $4,
// fields = $5 RETURNING id, form_id, workspace_id, name, fields, status, created_at, updated_at
func (q *Queries) SaveDraft(ctx context.Context, arg SaveDraftParams) (Form, error) {
row := q.db.QueryRow(ctx, saveDraft,
// status = coalesce(nullif($6, '')::form_status, forms.status),
// fields = coalesce($5, forms.fields) RETURNING id, form_id, workspace_id, name, fields, status, created_at, updated_at
func (q *Queries) SaveForm(ctx context.Context, arg SaveFormParams) (Form, error) {
row := q.db.QueryRow(ctx, saveForm,
arg.ID,
arg.FormID,
arg.WorkspaceID,
arg.Name,
arg.Fields,
arg.Status,
)
var i Form
err := row.Scan(

View file

@ -40,6 +40,7 @@ func Mount(router chi.Router, f *frm.Frm) {
form.Delete("/", handlers.DeleteForm)
form.Post("/draft", handlers.NewDraft)
form.Put("/publish", handlers.PublishDraft)
form.Put("/status", handlers.ChangeStatus)
form.Put("/fields/order", handlers.UpdateFieldOrder)
form.Put("/settings", handlers.UpdateSettings)
form.Post("/fields", handlers.NewField)