mirror of
https://github.com/acaloiaro/frm
synced 2026-07-21 10:12:23 +00:00
feat: add form clone support
This commit is contained in:
parent
ff10a77d72
commit
62afa56afd
10 changed files with 205 additions and 53 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE form_status DROP VALUE 'archived';
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE form_status ADD VALUE IF NOT EXISTS 'archived';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
47
frm.go
47
frm.go
|
|
@ -11,7 +11,8 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
EventDraftCreated = "frmDraftCreated" // htmx event sent when new drafts are created
|
||||
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
|
||||
|
|
|
|||
58
frm_test.go
58
frm_test.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,38 +637,38 @@ 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,
|
||||
ID: *formID,
|
||||
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 {
|
||||
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
|
||||
draft = (internal.Form)(copy)
|
||||
} 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, 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))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type FormStatus string
|
|||
const (
|
||||
FormStatusPublished FormStatus = "published"
|
||||
FormStatusDraft FormStatus = "draft"
|
||||
FormStatusArchived FormStatus = "archived"
|
||||
)
|
||||
|
||||
func (e *FormStatus) Scan(src interface{}) error {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue