From 0efdd6f15b4451ec909996fa761529d4206d7309 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:45:08 +0200 Subject: [PATCH 1/4] Add Text Display form inputs Support Text Display fields with Discord markdown content, validation, previews, gallery snapshots, and a 4000-character total limit across backend and frontend form management. --- .../http/endpoints/api/forms/updateinputs.go | 82 +++++++++--- .../http/endpoints/api/gallery/import_form.go | 1 + .../http/endpoints/api/gallery/resubmit.go | 2 +- .../http/endpoints/api/gallery/submit_form.go | 1 + backend/go.mod | 2 +- frontend/src/components/FormInputRow.tsx | 125 ++++++++++++------ .../gallery/GalleryListingPreview.tsx | 31 +++-- frontend/src/pages/manage/forms/_index.tsx | 1 + frontend/src/pages/manage/forms/edit.tsx | 45 ++++++- .../manage/setup/components/FormsStep.tsx | 5 + frontend/src/types.d.ts | 2 + 11 files changed, 227 insertions(+), 70 deletions(-) diff --git a/backend/app/http/endpoints/api/forms/updateinputs.go b/backend/app/http/endpoints/api/forms/updateinputs.go index 7b0ddc64..b1e34554 100644 --- a/backend/app/http/endpoints/api/forms/updateinputs.go +++ b/backend/app/http/endpoints/api/forms/updateinputs.go @@ -9,6 +9,7 @@ import ( "sort" "strconv" "strings" + "unicode/utf8" "github.com/TicketsBot-cloud/common/featureflags" "github.com/TicketsBot-cloud/database" @@ -30,10 +31,11 @@ type ( } inputCreateBody struct { - Label string `json:"label" validate:"required,min=1,max=45"` + Label string `json:"label" validate:"omitempty,max=45"` Description *string `json:"description,omitempty" validate:"omitempty,max=100"` Placeholder *string `json:"placeholder,omitempty" validate:"omitempty,min=1,max=100"` - Type int `json:"type" validate:"required,oneof=3 4 5 6 7 8 21 22"` + Content *string `json:"content,omitempty" validate:"omitempty,max=4000"` + Type int `json:"type" validate:"required,oneof=3 4 5 6 7 8 10 21 22"` Position int `json:"position" validate:"required,min=1,max=5"` Style component.TextStyleTypes `json:"style" validate:"omitempty,required,min=1,max=2"` Required bool `json:"required"` @@ -93,6 +95,14 @@ func UpdateInputs(c *gin.Context) { return } + for i := range data.Create { + data.Create[i] = normalizeTypeFields(data.Create[i]) + } + + for i := range data.Update { + data.Update[i].inputCreateBody = normalizeTypeFields(data.Update[i].inputCreateBody) + } + if err := validate.Struct(data); err != nil { var validationErrors validator.ValidationErrors if !errors.As(err, &validationErrors) { @@ -206,6 +216,11 @@ func UpdateInputs(c *gin.Context) { } } + if contentLength := totalTextDisplayLength(data); contentLength > 4000 { + c.JSON(400, utils.ErrorStr("Text display content must be at most 4000 characters in total (current: %d characters)", contentLength)) + return + } + if err := saveInputs(c, formId, data, existingInputs); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, app.NewError(err, "Failed to save form inputs to database")) return @@ -256,6 +271,23 @@ func arePositionsCorrect(body updateInputsBody) bool { return true } +func totalTextDisplayLength(body updateInputsBody) int { + var total int + for _, input := range body.Create { + if input.Type == 10 && input.Content != nil { + total += utf8.RuneCountInString(*input.Content) + } + } + + for _, input := range body.Update { + if input.Type == 10 && input.Content != nil { + total += utf8.RuneCountInString(*input.Content) + } + } + + return total +} + func validateUniqueOptionValues(options []inputOption) error { if len(options) == 0 { return nil @@ -307,6 +339,17 @@ func validateApiBodyTemplate(config *inputApiConfigBody) error { } func validateInput(input inputCreateBody, optionTypes map[int]string) error { + if input.Type == 10 { + if input.Content == nil || strings.TrimSpace(*input.Content) == "" { + return fmt.Errorf("Text display content is required") + } + return nil + } + + if strings.TrimSpace(input.Label) == "" { + return fmt.Errorf("Field labels are required") + } + if input.Type == 4 && input.MaxLength < 1 { return fmt.Errorf("Text input max length must be at least 1") } @@ -358,7 +401,7 @@ func validateInput(input inputCreateBody, optionTypes map[int]string) error { } func normalizeLengths(input inputCreateBody) (*uint16, *uint16) { - if input.Type == 21 { + if input.Type == 10 || input.Type == 21 { return nil, nil } @@ -393,6 +436,19 @@ func normalizeLengths(input inputCreateBody) (*uint16, *uint16) { return &minLength, &maxLength } +func normalizeTypeFields(input inputCreateBody) inputCreateBody { + if input.Type == 10 { + input.Label = "" + input.Required = false + input.Placeholder = nil + input.Description = nil + } else { + input.Content = nil + } + + return input +} + func saveInputs(ctx context.Context, formId int, data updateInputsBody, existingInputs []database.FormInput) error { // We can now update in the database tx, err := dbclient.Client.BeginTx(ctx) @@ -426,6 +482,7 @@ func saveInputs(ctx context.Context, formId int, data updateInputsBody, existing Label: input.Label, Description: input.Description, Placeholder: input.Placeholder, + Content: input.Content, Required: input.Required, MinLength: minLengthPtr, MaxLength: maxLengthPtr, @@ -435,19 +492,11 @@ func saveInputs(ctx context.Context, formId int, data updateInputsBody, existing return err } - if wrapped.Type == 3 || wrapped.Type == 21 || wrapped.Type == 22 { // String Select, RadioGroup, CheckboxGroup - // Delete existing options - options, err := dbclient.Client.FormInputOption.GetOptions(ctx, wrapped.Id) - if err != nil { - return err - } - - for _, option := range options { - if err := dbclient.Client.FormInputOption.DeleteTx(ctx, tx, option.Id); err != nil { - return err - } - } + if err := dbclient.Client.FormInputOption.DeleteByFormInputTx(ctx, tx, wrapped.Id); err != nil { + return err + } + if wrapped.Type == 3 || wrapped.Type == 21 || wrapped.Type == 22 { // String Select, RadioGroup, CheckboxGroup // Add new options for i, opt := range input.Options { option := database.FormInputOption{ @@ -487,6 +536,7 @@ func saveInputs(ctx context.Context, formId int, data updateInputsBody, existing input.Label, input.Description, input.Placeholder, + input.Content, input.Required, minLengthPtr, maxLengthPtr, @@ -522,7 +572,7 @@ func saveInputs(ctx context.Context, formId int, data updateInputsBody, existing func saveApiConfig(ctx context.Context, tx pgx.Tx, formInputId int, input inputCreateBody) error { if input.Type != 3 { - return nil + return dbclient.Client.FormInputApiConfig.DeleteByFormInputTx(ctx, tx, formInputId) } existingConfig, hasExisting, err := dbclient.Client.FormInputApiConfig.GetTx(ctx, tx, formInputId) diff --git a/backend/app/http/endpoints/api/gallery/import_form.go b/backend/app/http/endpoints/api/gallery/import_form.go index b8685f13..f6f1bc2c 100644 --- a/backend/app/http/endpoints/api/gallery/import_form.go +++ b/backend/app/http/endpoints/api/gallery/import_form.go @@ -169,6 +169,7 @@ func createFormInputs(ctx *gin.Context, tx pgx.Tx, formId int, inputs []database input.Label, input.Description, input.Placeholder, + input.Content, input.Required, input.MinLength, input.MaxLength, diff --git a/backend/app/http/endpoints/api/gallery/resubmit.go b/backend/app/http/endpoints/api/gallery/resubmit.go index 659019ef..1987786b 100644 --- a/backend/app/http/endpoints/api/gallery/resubmit.go +++ b/backend/app/http/endpoints/api/gallery/resubmit.go @@ -240,7 +240,7 @@ func ResubmitHandler(ctx *gin.Context) { snapshotInputs[i] = database.GalleryFormInputSnapshot{ Type: input.Type, Position: input.Position, Style: input.Style, Label: input.Label, Description: input.Description, Placeholder: input.Placeholder, - Required: input.Required, MinLength: input.MinLength, MaxLength: input.MaxLength, + Content: input.Content, Required: input.Required, MinLength: input.MinLength, MaxLength: input.MaxLength, } if opts, exists := optionsByInput[input.Id]; exists { snapshotOpts := make([]database.GalleryFormInputOptionSnapshot, len(opts)) diff --git a/backend/app/http/endpoints/api/gallery/submit_form.go b/backend/app/http/endpoints/api/gallery/submit_form.go index f682fb5d..21a4f4a4 100644 --- a/backend/app/http/endpoints/api/gallery/submit_form.go +++ b/backend/app/http/endpoints/api/gallery/submit_form.go @@ -122,6 +122,7 @@ func SubmitFormHandler(ctx *gin.Context) { Label: input.Label, Description: input.Description, Placeholder: input.Placeholder, + Content: input.Content, Required: input.Required, MinLength: input.MinLength, MaxLength: input.MaxLength, diff --git a/backend/go.mod b/backend/go.mod index a82fb5d4..3b88830e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,7 @@ module github.com/ticketsbot-cloud/dashboard/backend go 1.26.0 -// replace github.com/TicketsBot-cloud/database => ../../database +replace github.com/TicketsBot-cloud/database => ../../database // replace github.com/TicketsBot-cloud/common => ../../common diff --git a/frontend/src/components/FormInputRow.tsx b/frontend/src/components/FormInputRow.tsx index 24de1be9..f95e0ed8 100644 --- a/frontend/src/components/FormInputRow.tsx +++ b/frontend/src/components/FormInputRow.tsx @@ -6,6 +6,7 @@ import Select from "./Select"; import RangeSlider from "./RangeSlider"; import NumberInput from "./NumberInput"; import Textarea from "./Textarea"; +import DiscordContent from "./discord/DiscordContent"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faChevronUp, faChevronDown, faTrash, faPlus } from "@fortawesome/free-solid-svg-icons"; import Slider from "./Slider"; @@ -30,6 +31,7 @@ interface FormInputRowProps { } const OPTION_TYPES = [3, 21, 22]; +const NO_RANGE_TYPES = [10, 21]; // Must match SecretHeaderMask on the API; sent back untouched to keep the stored secret. const SECRET_HEADER_MASK = "••••••••"; @@ -115,8 +117,12 @@ const FormInputRow: FC = ({ // Validation const hasInvalidLabel = - !input.label || input.label.trim().length === 0 || input.label.length > 45; - const hasInvalidDescription = !!input.description && input.description.length > 100; + input.type !== 10 && + (!input.label || input.label.trim().length === 0 || input.label.length > 45); + const contentLength = [...(input.content ?? "")].length; + const hasInvalidContent = input.type === 10 && (!input.content?.trim() || contentLength > 4000); + const hasInvalidDescription = + input.type !== 10 && !!input.description && input.description.length > 100; const minOptionsRequired = input.type === 21 ? 2 : 1; const maxOptionsAllowed = input.type === 21 || input.type === 22 ? 10 : 25; const hasNoOptions = @@ -175,6 +181,7 @@ const FormInputRow: FC = ({ const hasBlankOptions = blankOptions.length > 0; const hasValidationErrors = hasInvalidLabel || + hasInvalidContent || hasInvalidDescription || hasDuplicateValues || hasNoOptions || @@ -212,6 +219,7 @@ const FormInputRow: FC = ({ { label: "Channel Select", key: "8" }, { label: "Radio Group", key: "21" }, { label: "Checkbox Group", key: "22" }, + { label: "Text Display", key: "10" }, ]; const inputStyles = [ @@ -262,42 +270,46 @@ const FormInputRow: FC = ({ -
- handleChange("label", value)} - /> - handleChange("placeholder", value)} - /> -
- {!!input.label && input.label.length > 45 && ( - - )} + {input.type !== 10 && ( + <> +
+ handleChange("label", value)} + /> + handleChange("placeholder", value)} + /> +
+ {!!input.label && input.label.length > 45 && ( + + )} -
- handleChange("description", value)} - /> -
- {hasInvalidDescription && ( - +
+ handleChange("description", value)} + /> +
+ {hasInvalidDescription && ( + + )} + )} -
+