diff --git a/db/create_forms_test.go b/db/create_forms_test.go index 6fd3b71..f24f08c 100644 --- a/db/create_forms_test.go +++ b/db/create_forms_test.go @@ -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, diff --git a/db/migrations/20250211174136_add_form_status_archived.down.sql b/db/migrations/20250211174136_add_form_status_archived.down.sql new file mode 100644 index 0000000..0192854 --- /dev/null +++ b/db/migrations/20250211174136_add_form_status_archived.down.sql @@ -0,0 +1 @@ +ALTER TYPE form_status DROP VALUE 'archived'; diff --git a/db/migrations/20250211174136_add_form_status_archived.up.sql b/db/migrations/20250211174136_add_form_status_archived.up.sql new file mode 100644 index 0000000..4905e02 --- /dev/null +++ b/db/migrations/20250211174136_add_form_status_archived.up.sql @@ -0,0 +1 @@ +ALTER TYPE form_status ADD VALUE IF NOT EXISTS 'archived'; diff --git a/db/queries.sql b/db/queries.sql index f1df05e..ff8a3eb 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -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 diff --git a/frm.go b/frm.go index 03928aa..2e476e3 100644 --- a/frm.go +++ b/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 diff --git a/frm_test.go b/frm_test.go index 253deb7..fe54cb7 100644 --- a/frm_test.go +++ b/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 + } +} diff --git a/handlers/handlers.go b/handlers/handlers.go index e80dc81..ffc8124 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -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) diff --git a/internal/models.go b/internal/models.go index e8ef6a7..ad4ad80 100644 --- a/internal/models.go +++ b/internal/models.go @@ -17,6 +17,7 @@ type FormStatus string const ( FormStatusPublished FormStatus = "published" FormStatusDraft FormStatus = "draft" + FormStatusArchived FormStatus = "archived" ) func (e *FormStatus) Scan(src interface{}) error { diff --git a/internal/queries.sql.go b/internal/queries.sql.go index 2c38f0f..f83cf40 100644 --- a/internal/queries.sql.go +++ b/internal/queries.sql.go @@ -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( diff --git a/routers/frmchi/router.go b/routers/frmchi/router.go index 1c4817d..89dda3d 100644 --- a/routers/frmchi/router.go +++ b/routers/frmchi/router.go @@ -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)