From e2e8270f86973fb1b0f049e0f55370f4590671b7 Mon Sep 17 00:00:00 2001 From: rxdn <29165304+rxdn@users.noreply.github.com> Date: Sat, 23 Jul 2022 21:32:35 +0100 Subject: [PATCH] Improve UX --- app/http/endpoints/api/forms/createinput.go | 11 +- app/http/endpoints/api/forms/updateform.go | 16 +- app/http/endpoints/api/forms/updateinput.go | 2 +- app/http/endpoints/api/forms/updateinputs.go | 244 ++++++++++ app/http/server.go | 1 + .../src/components/manage/FormInputRow.svelte | 21 +- frontend/src/js/util.js | 4 + frontend/src/views/Forms.svelte | 416 ++++++++++++------ go.mod | 4 + go.sum | 2 - utils/utils.go | 32 ++ 11 files changed, 569 insertions(+), 184 deletions(-) create mode 100644 app/http/endpoints/api/forms/updateinputs.go diff --git a/app/http/endpoints/api/forms/createinput.go b/app/http/endpoints/api/forms/createinput.go index a6b1581..d270840 100644 --- a/app/http/endpoints/api/forms/createinput.go +++ b/app/http/endpoints/api/forms/createinput.go @@ -9,13 +9,6 @@ import ( "strconv" ) -type inputCreateBody struct { - Style component.TextStyleTypes `json:"style"` - Label string `json:"label"` - Placeholder *string `json:"placeholder"` - Optional bool `json:"optional"` -} - func CreateInput(ctx *gin.Context) { guildId := ctx.Keys["guildid"].(uint64) @@ -70,7 +63,7 @@ func CreateInput(ctx *gin.Context) { // 2^30 chance of collision customId := utils.RandString(30) - formInputId, err := dbclient.Client.FormInput.Create(formId, customId, uint8(data.Style), data.Label, data.Placeholder, !data.Optional) + formInputId, err := dbclient.Client.FormInput.Create(formId, customId, uint8(data.Style), data.Label, data.Placeholder, data.Required) if err != nil { ctx.JSON(500, utils.ErrorJson(err)) return @@ -83,7 +76,7 @@ func CreateInput(ctx *gin.Context) { Style: uint8(data.Style), Label: data.Label, Placeholder: data.Placeholder, - Required: !data.Optional, + Required: data.Required, }) } diff --git a/app/http/endpoints/api/forms/updateform.go b/app/http/endpoints/api/forms/updateform.go index d215063..8142382 100644 --- a/app/http/endpoints/api/forms/updateform.go +++ b/app/http/endpoints/api/forms/updateform.go @@ -16,7 +16,7 @@ func UpdateForm(ctx *gin.Context) { return } - if len(data.Title) > 255 { + if len(data.Title) > 45 { ctx.JSON(400, utils.ErrorStr("Title is too long")) return } @@ -29,9 +29,9 @@ func UpdateForm(ctx *gin.Context) { form, ok, err := dbclient.Client.Forms.Get(formId) if err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return - } + ctx.JSON(500, utils.ErrorJson(err)) + return + } if !ok { ctx.JSON(404, utils.ErrorStr("Form not found")) @@ -40,13 +40,13 @@ func UpdateForm(ctx *gin.Context) { if form.GuildId != guildId { ctx.JSON(403, utils.ErrorStr("Form does not belong to this guild")) - return + return } if err := dbclient.Client.Forms.UpdateTitle(formId, data.Title); err != nil { - ctx.JSON(500, utils.ErrorJson(err)) - return - } + ctx.JSON(500, utils.ErrorJson(err)) + return + } ctx.JSON(200, utils.SuccessResponse) } diff --git a/app/http/endpoints/api/forms/updateinput.go b/app/http/endpoints/api/forms/updateinput.go index 066d9d9..7d2ff9c 100644 --- a/app/http/endpoints/api/forms/updateinput.go +++ b/app/http/endpoints/api/forms/updateinput.go @@ -72,7 +72,7 @@ func UpdateInput(ctx *gin.Context) { Style: uint8(data.Style), Label: data.Label, Placeholder: data.Placeholder, - Required: !data.Optional, + Required: data.Required, } if err := dbclient.Client.FormInput.Update(newInput); err != nil { diff --git a/app/http/endpoints/api/forms/updateinputs.go b/app/http/endpoints/api/forms/updateinputs.go new file mode 100644 index 0000000..d53b68a --- /dev/null +++ b/app/http/endpoints/api/forms/updateinputs.go @@ -0,0 +1,244 @@ +package forms + +import ( + "context" + "fmt" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/rxdn/gdl/objects/interaction/component" + "sort" + "strconv" + "strings" +) + +type ( + updateInputsBody struct { + Create []inputCreateBody `json:"create" validate:"omitempty,dive"` + Update []inputUpdateBody `json:"update" validate:"omitempty,dive"` + Delete []int `json:"delete" validate:"omitempty"` + } + + inputCreateBody struct { + Label string `json:"label" validate:"required,min=1,max=45"` + Placeholder *string `json:"placeholder,omitempty" validate:"omitempty,min=1,max=100"` + Position int `json:"position" validate:"required,min=1,max=5"` + Style component.TextStyleTypes `json:"style" validate:"required,min=1,max=2"` + Required bool `json:"required"` + } + + inputUpdateBody struct { + Id int `json:"id" validate:"required"` + inputCreateBody `validate:"required,dive"` + } +) + +var validate = validator.New() + +func UpdateInputs(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + formId, err := strconv.Atoi(ctx.Param("form_id")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid form ID")) + return + } + + var data updateInputsBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + if err := validate.Struct(data); err != nil { + validationErrors, ok := err.(validator.ValidationErrors) + if !ok { + ctx.JSON(500, utils.ErrorStr("An error occurred while validating the integration")) + return + } + + formatted := "Your input contained the following errors:" + for _, validationError := range validationErrors { + formatted += fmt.Sprintf("\n%s", validationError.Error()) + } + + formatted = strings.TrimSuffix(formatted, "\n") + ctx.JSON(400, utils.ErrorStr(formatted)) + return + } + + fieldCount := len(data.Create) + len(data.Update) + if fieldCount <= 0 || fieldCount > 5 { + ctx.JSON(400, utils.ErrorStr("Forms must have between 1 and 5 inputs")) + return + } + + // Verify form exists and is from the right guild + form, ok, err := dbclient.Client.Forms.Get(formId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Form not found")) + return + } + + if form.GuildId != guildId { + ctx.JSON(403, utils.ErrorStr("Form does not belong to this guild")) + return + } + + existingInputs, err := dbclient.Client.FormInput.GetInputs(formId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Verify that the UPDATE inputs exist + for _, input := range data.Update { + if !utils.ExistsMap(existingInputs, input.Id, idMapper) { + ctx.JSON(400, utils.ErrorStr("Input (to be updated) not found")) + return + } + } + + // Verify that the DELETE inputs exist + for _, id := range data.Delete { + if !utils.ExistsMap(existingInputs, id, idMapper) { + ctx.JSON(400, utils.ErrorStr("Input (to be deleted) not found")) + return + } + } + + // Ensure no overlap between DELETE and UPDATE + for _, id := range data.Delete { + if utils.ExistsMap(data.Update, id, idMapperBody) { + ctx.JSON(400, utils.ErrorStr("Delete and update overlap")) + return + } + } + + // Verify that we are updating ALL inputs, excluding the ones to be deleted + var remainingExisting []int + for _, input := range existingInputs { + if !utils.Exists(data.Delete, input.Id) { + remainingExisting = append(remainingExisting, input.Id) + } + } + + // Now verify that the contents match exactly + if len(remainingExisting) != len(data.Update) { + ctx.JSON(400, utils.ErrorStr("All inputs must be included in the update array")) + return + } + + for _, input := range data.Update { + if !utils.Exists(remainingExisting, input.Id) { + ctx.JSON(400, utils.ErrorStr("All inputs must be included in the update array")) + return + } + } + + // Verify that the positions are unique, and are in ascending order + if !arePositionsCorrect(data) { + ctx.JSON(400, utils.ErrorStr("Positions must be unique and in ascending order")) + return + } + + if err := saveInputs(formId, data, existingInputs); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} + +func idMapper(input database.FormInput) int { + return input.Id +} + +func idMapperBody(input inputUpdateBody) int { + return input.Id +} + +func arePositionsCorrect(body updateInputsBody) bool { + var positions []int + for _, input := range body.Create { + positions = append(positions, input.Position) + } + + for _, input := range body.Update { + positions = append(positions, input.Position) + } + + sort.Slice(positions, func(i, j int) bool { + return positions[i] < positions[j] + }) + + for i, position := range positions { + if i+1 != position { + return false + } + } + + return true +} + +func saveInputs(formId int, data updateInputsBody, existingInputs []database.FormInput) error { + // We can now update in the database + tx, err := dbclient.Client.BeginTx() + if err != nil { + return err + } + + defer tx.Rollback(context.Background()) + + for _, id := range data.Delete { + if err := dbclient.Client.FormInput.DeleteTx(tx, id, formId); err != nil { + return err + } + } + + for _, input := range data.Update { + existing := utils.FindMap(existingInputs, input.Id, idMapper) + if existing == nil { + return fmt.Errorf("input %d does not exist", input.Id) + } + + wrapped := database.FormInput{ + Id: input.Id, + FormId: formId, + Position: input.Position, + CustomId: existing.CustomId, + Style: uint8(input.Style), + Label: input.Label, + Placeholder: input.Placeholder, + Required: input.Required, + } + + if err := dbclient.Client.FormInput.UpdateTx(tx, wrapped); err != nil { + return err + } + } + + for _, input := range data.Create { + if _, err := dbclient.Client.FormInput.CreateTx( + tx, + formId, + utils.RandString(30), + input.Position, + uint8(input.Style), + input.Label, + input.Placeholder, + input.Required, + ); err != nil { + return err + } + } + + return tx.Commit(context.Background()) +} diff --git a/app/http/server.go b/app/http/server.go index 7daa7f7..e6dda27 100644 --- a/app/http/server.go +++ b/app/http/server.go @@ -123,6 +123,7 @@ func StartServer() { guildAuthApiAdmin.PATCH("/forms/:form_id", rl(middleware.RateLimitTypeGuild, 30, time.Hour), api_forms.UpdateForm) guildAuthApiAdmin.DELETE("/forms/:form_id", api_forms.DeleteForm) guildAuthApiAdmin.POST("/forms/:form_id", api_forms.CreateInput) + guildAuthApiAdmin.PATCH("/forms/:form_id/inputs", api_forms.UpdateInputs) guildAuthApiAdmin.PATCH("/forms/:form_id/:input_id", api_forms.UpdateInput) guildAuthApiAdmin.PATCH("/forms/:form_id/:input_id/:direction", api_forms.SwapInput) guildAuthApiAdmin.DELETE("/forms/:form_id/:input_id", api_forms.DeleteInput) diff --git a/frontend/src/components/manage/FormInputRow.svelte b/frontend/src/components/manage/FormInputRow.svelte index be3fd75..38fd356 100644 --- a/frontend/src/components/manage/FormInputRow.svelte +++ b/frontend/src/components/manage/FormInputRow.svelte @@ -17,11 +17,6 @@ {/if} - {#if withSaveButton} -
- -
- {/if} {#if withDeleteButton}
@@ -41,7 +36,7 @@
- +
@@ -67,13 +62,6 @@ {/if}
-
- {#if withSaveButton} - - - - {/if} -
{#if withDeleteButton}
@@ -85,7 +73,7 @@
{/if} - {#if withCreateButton} + {#if withCreateButton && false}
@@ -105,7 +93,6 @@ import Checkbox from "../form/Checkbox.svelte"; export let withCreateButton = false; - export let withSaveButton = false; export let withDeleteButton = false; export let withDirectionButtons = false; export let disabled = false; @@ -121,10 +108,6 @@ dispatch('create', data); } - function forwardSave() { - dispatch('save', data); - } - function forwardDelete() { dispatch('delete', {}); } diff --git a/frontend/src/js/util.js b/frontend/src/js/util.js index e12cb78..0926796 100644 --- a/frontend/src/js/util.js +++ b/frontend/src/js/util.js @@ -40,3 +40,7 @@ export function colourToInt(colour) { export function intToColour(i) { return `#${i.toString(16)}` } + +export function nullIfBlank(s) { + return s === '' ? null : s; +} diff --git a/frontend/src/views/Forms.svelte b/frontend/src/views/Forms.svelte index c6a1dcc..cc7de6b 100644 --- a/frontend/src/views/Forms.svelte +++ b/frontend/src/views/Forms.svelte @@ -1,6 +1,6 @@
- + Forms
@@ -18,25 +18,31 @@

Manage Forms

-
-
+ {#if editingTitle && activeFormId !== null} +
+ +
+ +
+
+ {:else} +
- + {#each forms as form} {/each}
-
- {#if activeFormId !== null} -
- + -
- {/if} -
+ {/if} +
+ {/if}
{#if activeFormId !== null} @@ -45,189 +51,248 @@ editInput(activeFormId, input.id, e.detail)} - on:delete={() => deleteInput(activeFormId, input.id)} - on:move={(e) => changePosition(activeFormId, input.id, e.detail.direction)} /> + on:delete={() => deleteInput(activeFormId, input)} + on:move={(e) => changePosition(activeFormId, input, e.detail.direction)}/>
{/each} {/if} {#if activeFormId !== null} - = 5} - on:create={(e) => createInput(e.detail)}/> +
+
+
= 5}> + + Add Input +
+
+
{/if}
+ +
+ +
- + diff --git a/go.mod b/go.mod index 02bbef8..f0408c6 100644 --- a/go.mod +++ b/go.mod @@ -83,3 +83,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect nhooyr.io/websocket v1.8.4 // indirect ) + +replace ( + github.com/TicketsBot/database => "../database" +) diff --git a/go.sum b/go.sum index 6cd55db..1567759 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ github.com/TicketsBot/archiverclient v0.0.0-20220326163414-558fd52746dc h1:n15W8 github.com/TicketsBot/archiverclient v0.0.0-20220326163414-558fd52746dc/go.mod h1:2KcfHS0JnSsgcxZBs3NyWMXNQzEo67mBSGOyzHPWOCc= github.com/TicketsBot/common v0.0.0-20220703211704-f792aa9f0c42 h1:3/qnbrEfL8gqSbjJ4o7WKkdoPngmhjAGEXFwteEjpqs= github.com/TicketsBot/common v0.0.0-20220703211704-f792aa9f0c42/go.mod h1:WxHh6bY7KhIqdayeOp5f0Zj2NNi/7QqCQfMEqHnpdAM= -github.com/TicketsBot/database v0.0.0-20220712212403-61804b8beb18 h1:p3rr325yK5CqWQMBML1SzkC+mXx+SSaBq4PnyxeBYXA= -github.com/TicketsBot/database v0.0.0-20220712212403-61804b8beb18/go.mod h1:F57cywrZsnper1cy56Bx0c/HEsxQBLHz3Pl98WXblWw= github.com/TicketsBot/database v0.0.0-20220721214509-131e86b1a06c h1:eyAFQuKihRkfkSNg1xeIm9nHQZ1z2Qg46kS7LcLZNxk= github.com/TicketsBot/database v0.0.0-20220721214509-131e86b1a06c/go.mod h1:F57cywrZsnper1cy56Bx0c/HEsxQBLHz3Pl98WXblWw= github.com/TicketsBot/logarchiver v0.0.0-20220326162808-cdf0310f5e1c h1:OqGjFH6mbE6gd+NqI2ARJdtH3UUvhiAkD0r0fhGJK2s= diff --git a/utils/utils.go b/utils/utils.go index bf3731e..072e6d6 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -16,6 +16,38 @@ func Slice[T any](v ...T) []T { return v } +func Exists[T comparable](v []T, el T) bool { + for _, e := range v { + if e == el { + return true + } + } + + return false +} + +func ExistsMap[T any, U comparable](v []T, el U, mapper func(T) U) bool { + for _, e := range v { + mapped := mapper(e) + if mapped == el { + return true + } + } + + return false +} + +func FindMap[T any, U comparable](v []T, el U, mapper func(T) U) *T { + for _, e := range v { + mapped := mapper(e) + if mapped == el { + return &e + } + } + + return nil +} + func Must(err error) { if err != nil { panic(err)