Improve UX

This commit is contained in:
rxdn 2022-07-23 21:32:35 +01:00
parent 6cd41aa99a
commit e2e8270f86
11 changed files with 569 additions and 184 deletions

View File

@ -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,
})
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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())
}

View File

@ -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)

View File

@ -17,11 +17,6 @@
</Button>
</form>
{/if}
{#if withSaveButton}
<form on:submit|preventDefault={forwardSave} class="button-form">
<Button icon="fas fa-save">Save</Button>
</form>
{/if}
{#if withDeleteButton}
<form on:submit|preventDefault={forwardDelete} class="button-form">
<Button icon="fas fa-trash" danger={true}>Delete</Button>
@ -41,7 +36,7 @@
</Dropdown>
</div>
<div class="row">
<Checkbox label="Optional" bind:value={data.optional}/>
<Checkbox label="Required" bind:value={data.required}/>
</div>
</div>
</div>
@ -67,13 +62,6 @@
</div>
{/if}
<div class="row">
<div class="col-2-force">
{#if withSaveButton}
<form on:submit|preventDefault={forwardSave} class="button-form">
<Button icon="fas fa-save">Save</Button>
</form>
{/if}
</div>
<div class="col-2-force">
{#if withDeleteButton}
<form on:submit|preventDefault={forwardDelete} class="button-form">
@ -85,7 +73,7 @@
</div>
{/if}
{#if withCreateButton}
{#if withCreateButton && false}
<div class="row" style="justify-content: center; margin-top: 10px">
<Button type="submit" icon="fas fa-plus" {disabled}>Add Input</Button>
</div>
@ -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', {});
}

View File

@ -40,3 +40,7 @@ export function colourToInt(colour) {
export function intToColour(i) {
return `#${i.toString(16)}`
}
export function nullIfBlank(s) {
return s === '' ? null : s;
}

View File

@ -1,6 +1,6 @@
<div class="parent">
<div class="content">
<Card footer={false}>
<Card footer footerRight>
<span slot="title">Forms</span>
<div slot="body" class="body-wrapper">
<div class="section">
@ -18,25 +18,31 @@
<div class="section">
<h2 class="section-title">Manage Forms</h2>
<div class="col-1" style="flex-direction: row">
<div class="col-4" style="margin-right: 12px">
{#if editingTitle && activeFormId !== null}
<div class="row form-name-edit-wrapper">
<Input col4 label="Form Title" placeholder="Form Title" bind:value={renamedTitle}/>
<div class="form-name-save-wrapper">
<Button icon="fas fa-floppy-disk" fullWidth={windowWidth <= 950} on:click={updateTitle}>Save</Button>
</div>
</div>
{:else}
<div class="row form-select-row">
<div class="multiselect-super">
<Dropdown col1={true} bind:value={activeFormId}>
<Dropdown col1 bind:value={activeFormId}>
<option value={null}>Select a form...</option>
{#each forms as form}
<option value="{form.form_id}">{form.title}</option>
{/each}
</Dropdown>
</div>
</div>
{#if activeFormId !== null}
<div class="col-4">
<Button danger={true} type="button"
<Button on:click={() => editingTitle = true}>Rename Form</Button>
<Button danger type="button"
on:click={() => deleteForm(activeFormId)}>Delete {activeFormTitle}</Button>
</div>
{/if}
</div>
{/if}
<div class="manage">
{#if activeFormId !== null}
@ -45,20 +51,31 @@
<FormInputRow data={input} formId={activeFormId}
withSaveButton={true} withDeleteButton={true} withDirectionButtons={true}
index={i} {formLength}
on:save={(e) => 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)}/>
</div>
{/each}
{/if}
{#if activeFormId !== null}
<FormInputRow bind:data={inputCreationData} withCreateButton={true} disabled={formLength >= 5}
on:create={(e) => createInput(e.detail)}/>
<div class="row" style="justify-content: center; align-items: center; gap: 10px; margin-top: 10px">
<hr class="fill">
<div class="row add-input-container" class:add-input-disabled={formLength >= 5}>
<i class="fas fa-plus"></i>
<a on:click={addInput}>Add Input</a>
</div>
<hr class="fill">
</div>
{/if}
</div>
</div>
</div>
<div slot="footer">
<Button type="submit" icon="fas fa-floppy-disk" disabled={formLength === 0} on:click={saveInputs}>
Save
</Button>
</div>
</Card>
</div>
</div>
@ -67,7 +84,7 @@
<script>
import Card from "../components/Card.svelte";
import {notifyError, notifySuccess, withLoadingScreen} from '../js/util'
import {notifyError, notifySuccess, nullIfBlank, withLoadingScreen} from '../js/util'
import Button from "../components/Button.svelte";
import axios from "axios";
import {API_URL} from "../js/constants";
@ -84,18 +101,40 @@
let newTitle;
let forms = [];
let toDelete = {};
let activeFormId = null;
$: activeFormTitle = activeFormId !== null ? forms.find(f => f.form_id === activeFormId).title : 'Unknown';
let inputCreationData = {};
$: formLength = activeFormId !== null ? forms.find(f => f.form_id === activeFormId).inputs.length : 0;
let editingTitle = false;
let renamedTitle = "";
$: activeFormId, reflectTitle();
function reflectTitle() {
renamedTitle = activeFormId !== null ? forms.find(f => f.form_id === activeFormId).title : null;
}
$: windowWidth = 0;
function getForm(formId) {
return forms.find(form => form.form_id === formId);
}
async function updateTitle() {
const res = await axios.patch(`${API_URL}/api/${guildId}/forms/${activeFormId}`, {title: renamedTitle});
if (res.status !== 200) {
notifyError('Failed to update form title');
return;
}
editingTitle = false;
getForm(activeFormId).title = renamedTitle;
forms = forms;
notifySuccess('Form title updated');
}
async function createForm() {
let data = {
title: newTitle,
@ -116,6 +155,8 @@
activeFormId = null; // Error thrown from {#each forms.find} if we don't temporarily set this to null?
forms = [...forms, form];
activeFormId = form.form_id;
addInput();
}
async function deleteForm(id) {
@ -135,21 +176,22 @@
}
}
async function createInput(data) {
let mapped = {...data, style: parseInt(data.style)};
function addInput() {
if (formLength >= 5) return;
const res = await axios.post(`${API_URL}/api/${guildId}/forms/${activeFormId}`, mapped);
if (res.status !== 200) {
notifyError(res.data.error);
return;
}
const input = {
form_id: activeFormId,
position: formLength + 1,
style: "1",
label: "",
placeholder: "",
required: true,
is_new: true,
};
let form = getForm(res.data.form_id);
form.inputs = [...form.inputs, res.data];
const form = getForm(activeFormId);
form.inputs = [...form.inputs, input];
forms = forms;
inputCreationData = {"style": "1"};
notifySuccess('Form input created successfully');
}
async function editInput(formId, inputId, data) {
@ -168,42 +210,66 @@
notifySuccess('Form input updated successfully');
}
async function deleteInput(formId, inputId) {
const res = await axios.delete(`${API_URL}/api/${guildId}/forms/${formId}/${inputId}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
async function deleteInput(formId, input) {
let form = getForm(formId);
let idx = form.inputs.findIndex((i) => i === input);
form.inputs.splice(idx, 1);
for (let i = idx; i < form.inputs.length; i++) {
form.inputs[i].position--;
}
// TODO: delete keyword?
let form = getForm(formId);
form.inputs = form.inputs.filter(input => input.id !== inputId);
forms = forms;
notifySuccess('Form input deleted successfully');
if (!input.is_new) {
if (toDelete[formId] === undefined) {
toDelete[formId] = [];
}
async function changePosition(formId, inputId, direction) {
const res = await axios.patch(`${API_URL}/api/${guildId}/forms/${formId}/${inputId}/${direction}`);
if (res.status !== 200) {
notifyError(res.data.error);
return;
toDelete[formId] = [...toDelete[formId], input.id];
}
}
if (res.data.success) {
//let form = getForm(formId);
let form = forms.find(form => form.form_id === activeFormId);
let idx = form.inputs.findIndex((input) => input.id === inputId);
function changePosition(formId, input, direction) {
const form = getForm(formId);
let idx = form.inputs.findIndex((i) => i === input);
let inputs = form.inputs;
if (direction === "up") {
[inputs[idx-1], inputs[idx]] = [inputs[idx], inputs[idx-1]]
[inputs[idx - 1].position, inputs[idx].position] = [inputs[idx].position, inputs[idx - 1].position];
[inputs[idx - 1], inputs[idx]] = [inputs[idx], inputs[idx - 1]];
} else if (direction === "down") {
[inputs[idx+1], inputs[idx]] = [form.inputs[idx], form.inputs[idx+1]]
[inputs[idx + 1].position, inputs[idx].position] = [form.inputs[idx].position, form.inputs[idx + 1].position];
[inputs[idx + 1], inputs[idx]] = [form.inputs[idx], form.inputs[idx + 1]];
}
forms = forms;
}
async function saveInputs() {
const form = getForm(activeFormId);
const data = {
"create": form.inputs.filter(i => i.is_new === true)
.map(i => ({...i, style: parseInt(i.style), placeholder: nullIfBlank(i.placeholder)})),
"update": form.inputs.filter(i => !i.is_new)
.map(i => ({...i, style: parseInt(i.style), placeholder: nullIfBlank(i.placeholder)})),
"delete": toDelete[activeFormId] || [],
}
const res = await axios.patch(`${API_URL}/api/${guildId}/forms/${activeFormId}/inputs`, data);
if (res.status !== 204) {
notifyError(res.data.error);
return;
}
toDelete = {};
const formId = activeFormId;
await loadForms();
activeFormId = formId;
notifySuccess('Form updated successfully');
}
async function loadForms() {
@ -215,7 +281,6 @@
forms = res.data || [];
forms.flatMap(f => f.inputs).forEach(i => {
i.optional = !i.required;
i.style = i.style.toString();
});
@ -276,6 +341,14 @@
margin-bottom: 4px;
}
hr.fill {
border-top: 1px solid #777;
border-bottom: 0;
border-left: 0;
border-right: 0;
flex: 1;
}
.row {
display: flex;
flex-direction: row;
@ -284,6 +357,26 @@
height: 100%;
}
.form-select-row {
justify-content: flex-start;
gap: 12px;
max-height: 40px;
}
.form-name-edit-wrapper {
justify-content: flex-start;
gap: 12px;
}
.form-name-save-wrapper {
height: 48px;
align-self: flex-end;
}
.multiselect-super {
width: 31%;
}
.manage {
display: flex;
flex-direction: column;
@ -293,6 +386,22 @@
margin-top: 12px;
}
.add-input-container {
display: flex;
align-items: center;
gap: 4px;
width: unset !important;
}
.add-input-container > * {
cursor: pointer;
}
.add-input-disabled > *{
cursor: default !important;
color: #777 !important;
}
#creation-row {
justify-content: flex-start !important;
}
@ -314,5 +423,22 @@
#create-button-wrapper {
margin-left: unset;
}
.form-select-row {
max-height: unset;
gap: 8px;
}
.multiselect-super {
width: 100%;
}
.form-name-edit-wrapper {
gap: unset;
}
.form-name-save-wrapper {
width: 100%;
}
}
</style>

4
go.mod
View File

@ -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"
)

2
go.sum
View File

@ -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=

View File

@ -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)