mirror of
https://github.com/acaloiaro/frm
synced 2026-07-21 18:29:12 +00:00
feat: publish form from a draft
This commit is contained in:
parent
15518cc1b4
commit
92378b5d1d
13 changed files with 1009 additions and 676 deletions
|
|
@ -71,20 +71,21 @@ func TestCreateAndUpdateForm(t *testing.T) {
|
|||
t.Error(err)
|
||||
return
|
||||
}
|
||||
f, err := internal.Q(ctx, frms.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
|
||||
ID: 1,
|
||||
Name: "hello world",
|
||||
Fields: fields,
|
||||
f, err := internal.Q(ctx, frms.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{
|
||||
Name: "hello world",
|
||||
Fields: fields,
|
||||
WorkspaceID: frms.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
f, err = internal.Q(ctx, frms.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
|
||||
ID: 1,
|
||||
Name: nameUpdate,
|
||||
Fields: updatedFields,
|
||||
f, err = internal.Q(ctx, frms.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{
|
||||
ID: 1,
|
||||
Name: nameUpdate,
|
||||
Fields: updatedFields,
|
||||
WorkspaceID: frms.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
DROP TABLE IF EXISTS forms;
|
||||
DROP TABLE IF EXISTS draft_forms;
|
||||
DROP SEQUENCE IF EXISTS forms_ids;
|
||||
DROP SEQUENCE IF EXISTS draft_forms_ids;
|
||||
DROP SEQUENCE IF EXISTS form_ids;
|
||||
DROP TYPE IF EXISTS form_status;
|
||||
|
|
|
|||
|
|
@ -3,30 +3,27 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|||
|
||||
-- create the forms pk sequence
|
||||
CREATE SEQUENCE IF NOT EXISTS form_ids START 1;
|
||||
CREATE SEQUENCE IF NOT EXISTS draft_form_ids START 1;
|
||||
|
||||
-- create form statuses enum
|
||||
CREATE TYPE form_status AS ENUM (
|
||||
'published',
|
||||
'draft'
|
||||
);
|
||||
|
||||
-- the forms table contains all forms
|
||||
-- workspaces represent users, tenants, groupings, etc. in external systems
|
||||
CREATE TABLE IF NOT EXISTS forms (
|
||||
id BIGINT PRIMARY KEY DEFAULT nextval('form_ids'),
|
||||
form_id BIGINT REFERENCES forms(id),
|
||||
workspace_id UUID NOT NULL,
|
||||
name text not null,
|
||||
fields jsonb default '{}' NOT NULL,
|
||||
status form_status default 'draft' NOT NULL,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS draft_forms (
|
||||
id BIGINT PRIMARY KEY DEFAULT nextval('draft_form_ids'),
|
||||
form_id BIGINT REFERENCES forms(id),
|
||||
workspace_id UUID,
|
||||
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 table draft_forms IS 'Draft forms allow forms to be edited in situ and act as a staging ground for Forms';
|
||||
COMMENT ON column forms.workspace_id IS 'a namespace for the form';
|
||||
COMMENT ON column forms.fields IS 'all form fields are serialized to JSON, see types.FormFields for structure details';
|
||||
|
|
|
|||
|
|
@ -5,17 +5,82 @@ FROM forms
|
|||
WHERE workspace_id = @workspace_id
|
||||
AND id = @id;
|
||||
|
||||
-- name: GetDraft :one
|
||||
|
||||
SELECT *
|
||||
FROM forms
|
||||
WHERE workspace_id = @workspace_id
|
||||
AND id = @id
|
||||
AND status = 'draft';
|
||||
|
||||
-- name: ListForms :many
|
||||
|
||||
SELECT *
|
||||
FROM forms
|
||||
WHERE workspace_id = @workspace_id;
|
||||
|
||||
-- name: SaveForm :one
|
||||
-- name: ListDrafts :many
|
||||
|
||||
INSERT INTO forms (id, workspace_id, name, fields)
|
||||
VALUES (coalesce(nullif(@id, 0), nextval('form_ids'))::bigint, @workspace_id, @name, @fields) ON conflict(id) DO
|
||||
SELECT *
|
||||
FROM forms
|
||||
WHERE workspace_id = @workspace_id
|
||||
AND form_id = @form_id
|
||||
AND status = 'draft';
|
||||
|
||||
-- name: SaveDraft :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
|
||||
UPDATE
|
||||
SET updated_at = timezone('utc', now()),
|
||||
name = @name,
|
||||
fields = @fields RETURNING *;
|
||||
|
||||
-- name: PublishDraft :one
|
||||
WITH draft AS
|
||||
(SELECT CASE
|
||||
WHEN form_id IS NOT NULL THEN form_id
|
||||
ELSE nextval('form_ids')
|
||||
END AS id,
|
||||
form_id,
|
||||
workspace_id,
|
||||
name,
|
||||
fields,
|
||||
'published'
|
||||
FROM forms
|
||||
WHERE forms.id = @id)
|
||||
INSERT INTO forms(id, form_id, workspace_id, name, fields, status)
|
||||
VALUES ((SELECT id FROM draft), NULL, (SELECT workspace_id FROM draft), (SELECT name FROM draft), (SELECT fields FROM draft), 'published') ON conflict(id) DO
|
||||
UPDATE
|
||||
SET updated_at = timezone('utc', now()),
|
||||
form_id = NULL,
|
||||
workspace_id =
|
||||
(SELECT workspace_id
|
||||
FROM draft),
|
||||
name =
|
||||
(SELECT name
|
||||
FROM draft),
|
||||
fields =
|
||||
(SELECT fields
|
||||
FROM draft),
|
||||
status = 'published' RETURNING *;
|
||||
|
||||
-- update
|
||||
-- INSERT INTO forms(id, form_id, workspace_id, name, fields, status)
|
||||
-- SELECT CASE
|
||||
-- WHEN form_id IS NOT NULL THEN form_id
|
||||
-- ELSE nextval('form_ids')
|
||||
-- END AS id,
|
||||
-- form_id,
|
||||
-- workspace_id,
|
||||
-- name,
|
||||
-- fields,
|
||||
-- 'published'
|
||||
-- FROM forms
|
||||
-- WHERE forms.id = @id ON conflict(id) DO
|
||||
-- UPDATE
|
||||
-- SET updated_at = timezone('utc', now()),
|
||||
-- form_id = NULL,
|
||||
-- name = forms.name,
|
||||
-- fields = forms.fields,
|
||||
-- status = 'published' RETURNING *;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func FormEditor(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
form, err := internal.Q(ctx, f.PostgresURL).GetForm(r.Context(), internal.GetFormParams{
|
||||
draft, err := internal.Q(ctx, f.PostgresURL).GetDraft(r.Context(), internal.GetDraftParams{
|
||||
WorkspaceID: f.WorkspaceID,
|
||||
ID: *id,
|
||||
})
|
||||
|
|
@ -59,7 +59,7 @@ func FormEditor(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
err = ui.Builder((frm.Form)(form)).Render(r.Context(), w)
|
||||
err = ui.Builder((frm.Form)(draft)).Render(r.Context(), w)
|
||||
if err != nil {
|
||||
slog.Error("unable to render builder", slog.Any("error", err))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
|
@ -80,7 +80,7 @@ func UpdateFieldOrder(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, internal.GetFormParams{
|
||||
draft, err := internal.Q(ctx, f.PostgresURL).GetDraft(ctx, internal.GetDraftParams{
|
||||
WorkspaceID: f.WorkspaceID,
|
||||
ID: *formID,
|
||||
})
|
||||
|
|
@ -101,7 +101,7 @@ func UpdateFieldOrder(w http.ResponseWriter, r *http.Request) {
|
|||
switch key {
|
||||
case "order":
|
||||
for order, fieldID := range field {
|
||||
oldField := form.Fields[fieldID]
|
||||
oldField := draft.Fields[fieldID]
|
||||
oldField.Order = order
|
||||
updatedFields[fieldID] = oldField
|
||||
}
|
||||
|
|
@ -109,24 +109,24 @@ func UpdateFieldOrder(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
form.Fields = updatedFields
|
||||
form, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
|
||||
ID: form.ID,
|
||||
Name: form.Name,
|
||||
draft.Fields = updatedFields
|
||||
draft, err = internal.Q(ctx, f.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{
|
||||
ID: draft.ID,
|
||||
Name: draft.Name,
|
||||
Fields: updatedFields,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("unable to save form", slog.Any("error", err))
|
||||
slog.Error("unable to save draft", slog.Any("error", err))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Re-render the form fields form UI
|
||||
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
|
||||
err = ui.FormFieldsForm((frm.Form)(draft)).Render(ctx, w)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
err = ui.FormView((frm.Form)(form), true).Render(ctx, w)
|
||||
err = ui.FormView((frm.Form)(draft), true).Render(ctx, w)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -170,7 +170,7 @@ func LogicConfiguratorStep3(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, internal.GetFormParams{
|
||||
draft, err := internal.Q(ctx, f.PostgresURL).GetDraft(ctx, internal.GetDraftParams{
|
||||
WorkspaceID: f.WorkspaceID,
|
||||
ID: *formID,
|
||||
})
|
||||
|
|
@ -179,13 +179,13 @@ func LogicConfiguratorStep3(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
targetField, ok := form.Fields[targetFieldID.String()]
|
||||
targetField, ok := draft.Fields[targetFieldID.String()]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
err = ui.LogicConfiguratorStepThree((frm.Form)(form), form.Fields[fieldID.String()], targetField).Render(ctx, w)
|
||||
err = ui.LogicConfiguratorStepThree((frm.Form)(draft), draft.Fields[fieldID.String()], targetField).Render(ctx, w)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -201,14 +201,14 @@ func UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
formID, err := formID(ctx)
|
||||
draftID, err := formID(ctx)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, internal.GetFormParams{
|
||||
form, err := internal.Q(ctx, f.PostgresURL).GetDraft(ctx, internal.GetDraftParams{
|
||||
WorkspaceID: f.WorkspaceID,
|
||||
ID: *formID,
|
||||
ID: *draftID,
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
|
@ -226,8 +226,8 @@ func UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||
if len(formName) == 0 {
|
||||
formName = "New form"
|
||||
}
|
||||
form, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
|
||||
ID: formID,
|
||||
form, err = internal.Q(ctx, f.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{
|
||||
ID: draftID,
|
||||
Name: formName,
|
||||
Fields: form.Fields,
|
||||
})
|
||||
|
|
@ -283,7 +283,7 @@ func NewField(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, internal.GetFormParams{
|
||||
draft, err := internal.Q(ctx, f.PostgresURL).GetDraft(ctx, internal.GetDraftParams{
|
||||
WorkspaceID: f.WorkspaceID,
|
||||
ID: *formID,
|
||||
})
|
||||
|
|
@ -305,7 +305,7 @@ func NewField(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
fields := form.Fields
|
||||
fields := draft.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}
|
||||
|
|
@ -325,9 +325,9 @@ func NewField(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
fields[fieldID.String()] = *newField
|
||||
_, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
|
||||
_, err = internal.Q(ctx, f.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{
|
||||
ID: formID,
|
||||
Name: form.Name,
|
||||
Name: draft.Name,
|
||||
Fields: fields,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -337,19 +337,19 @@ func NewField(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Re-render the form fields form UI
|
||||
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
|
||||
err = ui.FormFieldsForm((frm.Form)(draft)).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)
|
||||
err = ui.FormView((frm.Form)(draft), 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)
|
||||
err = ui.FormFieldConfigurator((frm.Form)(draft)).Render(ctx, w)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -370,7 +370,7 @@ func UpdateFields(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, internal.GetFormParams{
|
||||
draft, err := internal.Q(ctx, f.PostgresURL).GetDraft(ctx, internal.GetDraftParams{
|
||||
WorkspaceID: f.WorkspaceID,
|
||||
ID: *formID,
|
||||
})
|
||||
|
|
@ -386,7 +386,7 @@ func UpdateFields(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
updatedFields := form.Fields
|
||||
updatedFields := draft.Fields
|
||||
|
||||
// clear out the logic fields, ensuring logic only gets configured when the UI reflects fully configured logic
|
||||
for fid, f := range updatedFields {
|
||||
|
|
@ -410,24 +410,24 @@ func UpdateFields(w http.ResponseWriter, r *http.Request) {
|
|||
switch {
|
||||
case fieldName == "required":
|
||||
val := (len(fieldValues) > 1 && fieldValues[1] == "on") || (len(fieldValues) > 0 && fieldValues[0] == "on")
|
||||
oldField := form.Fields[fieldID]
|
||||
oldField := draft.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 := draft.Fields[fieldID]
|
||||
oldField.Hidden = val
|
||||
updatedFields[fieldID] = oldField
|
||||
case fieldName == "label":
|
||||
oldField := form.Fields[fieldID]
|
||||
oldField := draft.Fields[fieldID]
|
||||
oldField.Label = fieldValues[0]
|
||||
updatedFields[fieldID] = oldField
|
||||
case fieldName == "placeholder":
|
||||
oldField := form.Fields[fieldID]
|
||||
oldField := draft.Fields[fieldID]
|
||||
oldField.Placeholder = fieldValues[0]
|
||||
updatedFields[fieldID] = oldField
|
||||
case fieldName == "options":
|
||||
oldField := form.Fields[fieldID]
|
||||
oldField := draft.Fields[fieldID]
|
||||
oldField.Options = toFormFieldOption(oldField, fieldValues)
|
||||
updatedFields[fieldID] = oldField
|
||||
case fieldGroup == ui.FieldGroupLogic && fieldName == ui.FieldLogicTargetFieldID:
|
||||
|
|
@ -435,28 +435,28 @@ func UpdateFields(w http.ResponseWriter, r *http.Request) {
|
|||
if err != nil {
|
||||
continue
|
||||
}
|
||||
oldField := form.Fields[fieldID]
|
||||
oldField := draft.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]
|
||||
oldField := draft.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]
|
||||
oldField := draft.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]
|
||||
oldField := draft.Fields[fieldID]
|
||||
if oldField.Logic == nil {
|
||||
oldField.Logic = &types.FieldLogic{}
|
||||
}
|
||||
|
|
@ -467,10 +467,10 @@ func UpdateFields(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
form.Fields = updatedFields
|
||||
form, err = internal.Q(ctx, f.PostgresURL).SaveForm(ctx, internal.SaveFormParams{
|
||||
ID: form.ID,
|
||||
Name: form.Name,
|
||||
draft.Fields = updatedFields
|
||||
draft, err = internal.Q(ctx, f.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{
|
||||
ID: draft.ID,
|
||||
Name: draft.Name,
|
||||
Fields: updatedFields,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -479,13 +479,13 @@ func UpdateFields(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Re-render the form fields form UI
|
||||
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
|
||||
err = ui.FormFieldsForm((frm.Form)(draft)).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)
|
||||
err = ui.FormView((frm.Form)(draft), true).Render(ctx, w)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -512,7 +512,7 @@ func DeleteField(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
form, err := internal.Q(ctx, f.PostgresURL).GetForm(ctx, internal.GetFormParams{
|
||||
draft, err := internal.Q(ctx, f.PostgresURL).GetDraft(ctx, internal.GetDraftParams{
|
||||
WorkspaceID: f.WorkspaceID,
|
||||
ID: *formID,
|
||||
})
|
||||
|
|
@ -520,12 +520,12 @@ func DeleteField(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
updatedFields := form.Fields
|
||||
updatedFields := draft.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,
|
||||
draft.Fields = updatedFields
|
||||
draft, err = internal.Q(ctx, f.PostgresURL).SaveDraft(ctx, internal.SaveDraftParams{
|
||||
ID: draft.ID,
|
||||
Name: draft.Name,
|
||||
Fields: updatedFields,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -534,19 +534,19 @@ func DeleteField(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Re-render the form fields form UI
|
||||
err = ui.FormFieldsForm((frm.Form)(form)).Render(ctx, w)
|
||||
err = ui.FormFieldsForm((frm.Form)(draft)).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)
|
||||
err = ui.FormView((frm.Form)(draft), 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)
|
||||
err = ui.FormFieldConfigurator((frm.Form)(draft)).Render(ctx, w)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -558,6 +558,42 @@ func DeleteField(w http.ResponseWriter, r *http.Request) {
|
|||
func NewDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// PublishDraft copies drafts to published forms
|
||||
func PublishDraft(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
f, err := instance(ctx)
|
||||
if err != nil {
|
||||
slog.Error("unable to publish draft", "error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
formID, err := formID(ctx)
|
||||
if err != nil {
|
||||
slog.Error("unable to publish draft", "error", err)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
draft, err := internal.Q(ctx, f.PostgresURL).GetDraft(ctx, internal.GetDraftParams{
|
||||
WorkspaceID: f.WorkspaceID,
|
||||
ID: *formID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("unable to publish draft", "error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = internal.Q(ctx, f.PostgresURL).PublishDraft(ctx, draft.ID)
|
||||
if err != nil {
|
||||
slog.Error("unable to publish draft", "error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// instance returns the frm instance from the request context
|
||||
func instance(ctx context.Context) (i *frm.Frm, err error) {
|
||||
var ok bool
|
||||
|
|
|
|||
|
|
@ -5,31 +5,66 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/acaloiaro/frm/types"
|
||||
uuid "github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Draft forms allow forms to be edited in situ and act as a staging ground for Forms
|
||||
type DraftForm struct {
|
||||
ID int64 `json:"id"`
|
||||
FormID *int64 `json:"form_id"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Fields []byte `json:"fields"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
type FormStatus string
|
||||
|
||||
const (
|
||||
FormStatusPublished FormStatus = "published"
|
||||
FormStatusDraft FormStatus = "draft"
|
||||
)
|
||||
|
||||
func (e *FormStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = FormStatus(s)
|
||||
case string:
|
||||
*e = FormStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for FormStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullFormStatus struct {
|
||||
FormStatus FormStatus `json:"form_status"`
|
||||
Valid bool `json:"valid"` // Valid is true if FormStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullFormStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.FormStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.FormStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullFormStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.FormStatus), nil
|
||||
}
|
||||
|
||||
// Form contains all the data necesary to render a form
|
||||
type Form struct {
|
||||
ID int64 `json:"id"`
|
||||
ID int64 `json:"id"`
|
||||
FormID *int64 `json:"form_id"`
|
||||
// a namespace for the form
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
// all form fields are serialized to JSON, see types.FormFields for structure details
|
||||
Fields types.FormFields `json:"fields"`
|
||||
Status FormStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,46 @@ import (
|
|||
uuid "github.com/google/uuid"
|
||||
)
|
||||
|
||||
const getDraft = `-- name: GetDraft :one
|
||||
|
||||
SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
FROM forms
|
||||
WHERE workspace_id = $1
|
||||
AND id = $2
|
||||
AND status = 'draft'
|
||||
`
|
||||
|
||||
type GetDraftParams struct {
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// GetDraft
|
||||
//
|
||||
// SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
// FROM forms
|
||||
// WHERE workspace_id = $1
|
||||
// AND id = $2
|
||||
// AND status = 'draft'
|
||||
func (q *Queries) GetDraft(ctx context.Context, arg GetDraftParams) (Form, error) {
|
||||
row := q.db.QueryRow(ctx, getDraft, arg.WorkspaceID, arg.ID)
|
||||
var i Form
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.FormID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Fields,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getForm = `-- name: GetForm :one
|
||||
|
||||
SELECT id, workspace_id, name, fields, created_at, updated_at
|
||||
SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
FROM forms
|
||||
WHERE workspace_id = $1
|
||||
AND id = $2
|
||||
|
|
@ -27,7 +64,7 @@ type GetFormParams struct {
|
|||
|
||||
// GetForm
|
||||
//
|
||||
// SELECT id, workspace_id, name, fields, created_at, updated_at
|
||||
// SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
// FROM forms
|
||||
// WHERE workspace_id = $1
|
||||
// AND id = $2
|
||||
|
|
@ -36,29 +73,40 @@ func (q *Queries) GetForm(ctx context.Context, arg GetFormParams) (Form, error)
|
|||
var i Form
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.FormID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Fields,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listForms = `-- name: ListForms :many
|
||||
const listDrafts = `-- name: ListDrafts :many
|
||||
|
||||
SELECT id, workspace_id, name, fields, created_at, updated_at
|
||||
SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
FROM forms
|
||||
WHERE workspace_id = $1
|
||||
AND form_id = $2
|
||||
AND status = 'draft'
|
||||
`
|
||||
|
||||
// ListForms
|
||||
type ListDraftsParams struct {
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
FormID *int64 `json:"form_id"`
|
||||
}
|
||||
|
||||
// ListDrafts
|
||||
//
|
||||
// SELECT id, workspace_id, name, fields, created_at, updated_at
|
||||
// SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
// FROM forms
|
||||
// WHERE workspace_id = $1
|
||||
func (q *Queries) ListForms(ctx context.Context, workspaceID uuid.UUID) ([]Form, error) {
|
||||
rows, err := q.db.Query(ctx, listForms, workspaceID)
|
||||
// AND form_id = $2
|
||||
// AND status = 'draft'
|
||||
func (q *Queries) ListDrafts(ctx context.Context, arg ListDraftsParams) ([]Form, error) {
|
||||
rows, err := q.db.Query(ctx, listDrafts, arg.WorkspaceID, arg.FormID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -68,9 +116,11 @@ func (q *Queries) ListForms(ctx context.Context, workspaceID uuid.UUID) ([]Form,
|
|||
var i Form
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.FormID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Fields,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
|
|
@ -84,34 +134,152 @@ func (q *Queries) ListForms(ctx context.Context, workspaceID uuid.UUID) ([]Form,
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const saveForm = `-- name: SaveForm :one
|
||||
const listForms = `-- name: ListForms :many
|
||||
|
||||
INSERT INTO forms (id, workspace_id, name, fields)
|
||||
VALUES (coalesce(nullif($1, 0), nextval('form_ids'))::bigint, $2, $3, $4) ON conflict(id) DO
|
||||
UPDATE
|
||||
SET updated_at = timezone('utc', now()),
|
||||
name = $3,
|
||||
fields = $4 RETURNING id, workspace_id, name, fields, created_at, updated_at
|
||||
SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
FROM forms
|
||||
WHERE workspace_id = $1
|
||||
`
|
||||
|
||||
type SaveFormParams struct {
|
||||
// ListForms
|
||||
//
|
||||
// SELECT id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
// FROM forms
|
||||
// WHERE workspace_id = $1
|
||||
func (q *Queries) ListForms(ctx context.Context, workspaceID uuid.UUID) ([]Form, error) {
|
||||
rows, err := q.db.Query(ctx, listForms, workspaceID)
|
||||
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.FormID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Fields,
|
||||
&i.Status,
|
||||
&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 publishDraft = `-- name: PublishDraft :one
|
||||
WITH draft AS
|
||||
(SELECT CASE
|
||||
WHEN form_id IS NOT NULL THEN form_id
|
||||
ELSE nextval('form_ids')
|
||||
END AS id,
|
||||
form_id,
|
||||
workspace_id,
|
||||
name,
|
||||
fields,
|
||||
'published'
|
||||
FROM forms
|
||||
WHERE forms.id = $1)
|
||||
INSERT INTO forms(id, form_id, workspace_id, name, fields, status)
|
||||
VALUES ((SELECT id FROM draft), NULL, (SELECT workspace_id FROM draft), (SELECT name FROM draft), (SELECT fields FROM draft), 'published') ON conflict(id) DO
|
||||
UPDATE
|
||||
SET updated_at = timezone('utc', now()),
|
||||
form_id = NULL,
|
||||
workspace_id =
|
||||
(SELECT workspace_id
|
||||
FROM draft),
|
||||
name =
|
||||
(SELECT name
|
||||
FROM draft),
|
||||
fields =
|
||||
(SELECT fields
|
||||
FROM draft),
|
||||
status = 'published' RETURNING id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
`
|
||||
|
||||
// PublishDraft
|
||||
//
|
||||
// WITH draft AS
|
||||
// (SELECT CASE
|
||||
// WHEN form_id IS NOT NULL THEN form_id
|
||||
// ELSE nextval('form_ids')
|
||||
// END AS id,
|
||||
// form_id,
|
||||
// workspace_id,
|
||||
// name,
|
||||
// fields,
|
||||
// 'published'
|
||||
// FROM forms
|
||||
// WHERE forms.id = $1)
|
||||
// INSERT INTO forms(id, form_id, workspace_id, name, fields, status)
|
||||
// VALUES ((SELECT id FROM draft), NULL, (SELECT workspace_id FROM draft), (SELECT name FROM draft), (SELECT fields FROM draft), 'published') ON conflict(id) DO
|
||||
// UPDATE
|
||||
// SET updated_at = timezone('utc', now()),
|
||||
// form_id = NULL,
|
||||
// workspace_id =
|
||||
// (SELECT workspace_id
|
||||
// FROM draft),
|
||||
// name =
|
||||
// (SELECT name
|
||||
// FROM draft),
|
||||
// fields =
|
||||
// (SELECT fields
|
||||
// FROM draft),
|
||||
// status = 'published' RETURNING id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
func (q *Queries) PublishDraft(ctx context.Context, id int64) (Form, error) {
|
||||
row := q.db.QueryRow(ctx, publishDraft, id)
|
||||
var i Form
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.FormID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Fields,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const saveDraft = `-- name: SaveDraft :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
|
||||
UPDATE
|
||||
SET updated_at = timezone('utc', now()),
|
||||
name = $4,
|
||||
fields = $5 RETURNING id, form_id, workspace_id, name, fields, status, created_at, updated_at
|
||||
`
|
||||
|
||||
type SaveDraftParams struct {
|
||||
ID interface{} `json:"id"`
|
||||
FormID *int64 `json:"form_id"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Fields types.FormFields `json:"fields"`
|
||||
}
|
||||
|
||||
// SaveForm
|
||||
// SaveDraft
|
||||
//
|
||||
// INSERT INTO forms (id, workspace_id, name, fields)
|
||||
// VALUES (coalesce(nullif($1, 0), nextval('form_ids'))::bigint, $2, $3, $4) ON conflict(id) DO
|
||||
// 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
|
||||
// UPDATE
|
||||
// SET updated_at = timezone('utc', now()),
|
||||
// name = $3,
|
||||
// fields = $4 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,
|
||||
// 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,
|
||||
arg.ID,
|
||||
arg.FormID,
|
||||
arg.WorkspaceID,
|
||||
arg.Name,
|
||||
arg.Fields,
|
||||
|
|
@ -119,9 +287,11 @@ func (q *Queries) SaveForm(ctx context.Context, arg SaveFormParams) (Form, error
|
|||
var i Form
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.FormID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Fields,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ func Mount(router chi.Router, mountPoint string, f *frm.Frm) {
|
|||
rc := r.With(addRequestContext)
|
||||
rc.Get(fmt.Sprintf("/{%s}", urlParamFormID), handlers.FormEditor)
|
||||
rc.Get(fmt.Sprintf("/{%s}/draft", urlParamFormID), handlers.NewDraft)
|
||||
rc.Put(fmt.Sprintf("/{%s}/publish", urlParamFormID), handlers.PublishDraft)
|
||||
rc.Put(fmt.Sprintf("/{%s}/fields/order", urlParamFormID), handlers.UpdateFieldOrder)
|
||||
rc.Get(fmt.Sprintf("/{%s}/logic_configurator/{%s}/step3", urlParamFormID, urlParamFieldID), handlers.LogicConfiguratorStep3)
|
||||
rc.Put(fmt.Sprintf("/{%s}/settings", urlParamFormID), handlers.UpdateSettings)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ overrides:
|
|||
import: github.com/google/uuid
|
||||
package: uuid
|
||||
type: UUID
|
||||
- column: draft_forms.fields
|
||||
go_type:
|
||||
import: github.com/acaloiaro/frm/types
|
||||
type: FormFields
|
||||
- column: draft_forms.workspace_id
|
||||
go_type:
|
||||
import: github.com/google/uuid
|
||||
package: uuid
|
||||
type: UUID
|
||||
|
||||
sql:
|
||||
- engine: postgresql
|
||||
|
|
|
|||
2
types.go
2
types.go
|
|
@ -6,7 +6,7 @@ import (
|
|||
"github.com/acaloiaro/frm/internal"
|
||||
)
|
||||
|
||||
// Form is a form
|
||||
// Form is a published form
|
||||
type Form internal.Form
|
||||
type Forms []internal.Form
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,13 @@ templ FormBuilderNav(form frm.Form) {
|
|||
<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">
|
||||
<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"
|
||||
data-hx-put={ formUrl[string](ctx, form, "/publish") }
|
||||
data-hx-trigger="click"
|
||||
data-hx-swap="none"
|
||||
>
|
||||
<!---->
|
||||
<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><!---->
|
||||
|
|
|
|||
1139
ui/common_templ.go
1139
ui/common_templ.go
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,8 @@
|
|||
</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>
|
||||
<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\" data-hx-put=\"
|
||||
\" data-hx-trigger=\"click\" data-hx-swap=\"none\"><!----><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\">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
Loading…
Reference in a new issue