From 1053248e666446c3d3eb209c31b8623f9f1f81f1 Mon Sep 17 00:00:00 2001 From: Mohsen Mirzakhani Date: Sun, 26 Jan 2025 21:59:55 +0100 Subject: [PATCH] request variables --- internal/domain/requests.go | 22 ++ internal/domain/rest.go | 3 +- ui/pages/requests/component/variables.go | 338 +++++++++++++++++++++++ ui/pages/requests/restful/request.go | 22 +- ui/widgets/checkbox.go | 4 +- ui/widgets/numeric_editor.go | 43 +++ 6 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 ui/pages/requests/component/variables.go create mode 100644 ui/widgets/numeric_editor.go diff --git a/internal/domain/requests.go b/internal/domain/requests.go index d1e1d323..fc989bf9 100644 --- a/internal/domain/requests.go +++ b/internal/domain/requests.go @@ -261,6 +261,28 @@ type SShTunnel struct { Flags []string `yaml:"flags"` } +type VariableFrom string + +func (v VariableFrom) String() string { + return string(v) +} + +const ( + VariableFromBody VariableFrom = "body" + VariableFromHeader VariableFrom = "header" + VariableFromCookies VariableFrom = "cookies" +) + +type Variable struct { + ID string `yaml:"id"` // Unique identifier + TargetEnvVariable string `yaml:"TargetEnvVariable"` // The environment variable to set + From VariableFrom `yaml:"from"` // Source: "body", "header", "cookie" + SourceKey string `yaml:"sourceKey"` // For "header" or "cookie", specify the key name + OnStatusCode int `yaml:"onStatusCode"` // Trigger on a specific status code + JsonPath string `yaml:"jsonPath"` // JSONPath for extracting value (for "body") + Enable bool `yaml:"enable"` // Enable or disable the variable +} + func (r *Request) Clone() *Request { clone := *r clone.MetaData.ID = uuid.NewString() diff --git a/internal/domain/rest.go b/internal/domain/rest.go index 4cab8d18..85e4a3a1 100644 --- a/internal/domain/rest.go +++ b/internal/domain/rest.go @@ -35,7 +35,8 @@ type HTTPRequest struct { Body Body `yaml:"body"` - Auth Auth `yaml:"auth"` + Auth Auth `yaml:"auth"` + Variables []Variable `yaml:"variables"` PreRequest PreRequest `yaml:"preRequest"` PostRequest PostRequest `yaml:"postRequest"` diff --git a/ui/pages/requests/component/variables.go b/ui/pages/requests/component/variables.go new file mode 100644 index 00000000..60938757 --- /dev/null +++ b/ui/pages/requests/component/variables.go @@ -0,0 +1,338 @@ +package component + +import ( + "strconv" + + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "github.com/google/uuid" + + "github.com/chapar-rest/chapar/internal/domain" + "github.com/chapar-rest/chapar/ui/chapartheme" + "github.com/chapar-rest/chapar/ui/keys" + "github.com/chapar-rest/chapar/ui/widgets" +) + +type Variables struct { + theme *chapartheme.Theme + + Items []*Variable + addButton *widgets.IconButton + list *widget.List + + onSelectFile func(id string) + onChanged func(values []domain.Variable) + + preview string +} + +type Variable struct { + Identifier string + TargetEnvVariable string // The environment variable to set + From domain.VariableFrom // Source: "body", "header", "cookie" + SourceKey string // For "header" or "cookie", specify the key name + OnStatusCode int // Trigger on a specific status code + JsonPath string // JSONPath for extracting value (for "body") + Enable bool // Enable or disable this variable + + targetEnvEditor *widget.Editor + fromDropDown *widgets.DropDown + sourceKeyEditor *widget.Editor + onStatusCodeEditor *widgets.NumericEditor + jsonPathCodeEditor *widget.Editor + + enableBool *widget.Bool + deleteButton widget.Clickable +} + +func NewVariables(theme *chapartheme.Theme, items ...*Variable) *Variables { + f := &Variables{ + theme: theme, + addButton: &widgets.IconButton{ + Icon: widgets.PlusIcon, + Size: unit.Dp(20), + Clickable: &widget.Clickable{}, + }, + list: &widget.List{ + List: layout.List{ + Axis: layout.Vertical, + }, + }, + } + + for _, item := range items { + f.addItem(item) + } + + f.addButton.OnClick = func() { + f.addItem(NewVariable()) + if f.onChanged != nil { + f.onChanged(f.GetValues()) + } + } + + return f +} + +func (f *Variables) SetOnChanged(fn func(values []domain.Variable)) { + f.onChanged = fn +} + +func (f *Variables) GetValues() []domain.Variable { + values := make([]domain.Variable, 0, len(f.Items)) + for _, item := range f.Items { + values = append(values, domain.Variable{ + ID: item.Identifier, + TargetEnvVariable: item.TargetEnvVariable, + From: item.From, + SourceKey: item.SourceKey, + OnStatusCode: item.OnStatusCode, + JsonPath: item.JsonPath, + Enable: item.Enable, + }) + } + return values +} + +func (f *Variables) SetValues(values []domain.Variable) { + f.Items = make([]*Variable, 0, len(values)) + for _, item := range values { + f.addItem(&Variable{ + Identifier: item.ID, + TargetEnvVariable: item.TargetEnvVariable, + From: item.From, + SourceKey: item.SourceKey, + OnStatusCode: item.OnStatusCode, + JsonPath: item.JsonPath, + Enable: item.Enable, + }) + } +} + +func (f *Variables) addItem(item *Variable) { + item.fromDropDown = widgets.NewDropDownWithoutBorder( + f.theme, + widgets.NewDropDownOption("Body").WithIdentifier("body").WithValue("body"), + widgets.NewDropDownOption("Header").WithIdentifier("header").WithValue("header"), + widgets.NewDropDownOption("Cookie").WithIdentifier("cookie").WithValue("cookie"), + ) + item.fromDropDown.SetSelectedByValue(item.From.String()) + item.fromDropDown.MinWidth = unit.Dp(60) + item.fromDropDown.MaxWidth = unit.Dp(80) + + item.targetEnvEditor = &widget.Editor{SingleLine: true} + item.targetEnvEditor.SetText(item.TargetEnvVariable) + + item.sourceKeyEditor = &widget.Editor{SingleLine: true} + item.sourceKeyEditor.SetText(item.SourceKey) + + item.onStatusCodeEditor = &widgets.NumericEditor{Editor: widget.Editor{SingleLine: true}} + item.onStatusCodeEditor.Editor.SetText(strconv.Itoa(item.OnStatusCode)) + + item.enableBool = new(widget.Bool) + item.enableBool.Value = item.Enable + + item.fromDropDown.SetOnChanged(func(selected string) { + item.From = domain.VariableFrom(selected) + f.triggerChanged() + }) + + item.jsonPathCodeEditor = &widget.Editor{SingleLine: true} + item.jsonPathCodeEditor.SetText(item.JsonPath) + + f.Items = append(f.Items, item) +} + +func NewVariable() *Variable { + return &Variable{ + Identifier: uuid.NewString(), + TargetEnvVariable: "", + From: domain.VariableFromBody, + SourceKey: "", + OnStatusCode: 0, + JsonPath: "", + Enable: true, + } +} + +func (f *Variables) itemLayout(gtx layout.Context, theme *chapartheme.Theme, item *Variable) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, f.itemLayouts(gtx, theme, item)...) +} + +func (f *Variables) triggerChanged() { + if f.onChanged != nil { + f.onChanged(f.GetValues()) + } +} + +func (f *Variables) itemLayouts(gtx layout.Context, theme *chapartheme.Theme, item *Variable) []layout.FlexChild { + keys.OnEditorChange(gtx, item.targetEnvEditor, func() { + item.TargetEnvVariable = item.targetEnvEditor.Text() + f.triggerChanged() + }) + + keys.OnEditorChange(gtx, item.sourceKeyEditor, func() { + item.SourceKey = item.sourceKeyEditor.Text() + f.triggerChanged() + }) + + keys.OnEditorChange(gtx, &item.onStatusCodeEditor.Editor, func() { + item.OnStatusCode = item.onStatusCodeEditor.Value() + f.triggerChanged() + }) + + keys.OnEditorChange(gtx, item.jsonPathCodeEditor, func() { + item.JsonPath = item.jsonPathCodeEditor.Text() + f.triggerChanged() + }) + + if item.enableBool.Update(gtx) { + item.Enable = item.enableBool.Value + f.triggerChanged() + } + + items := []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + ch := widgets.CheckBox(theme.Material(), item.enableBool, "") + ch.IconColor = theme.CheckBoxColor + return ch.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Left: unit.Dp(1), Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.X = gtx.Dp(unit.Dp(100)) + editor := material.Editor(theme.Material(), item.sourceKeyEditor, "Target") + editor.SelectionColor = theme.TextSelectionColor + return editor.Layout(gtx) + }) + }), + widgets.DrawLineFlex(theme.TableBorderColor, unit.Dp(35), unit.Dp(1)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return item.fromDropDown.Layout(gtx, theme) + }), + widgets.DrawLineFlex(theme.TableBorderColor, unit.Dp(35), unit.Dp(1)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Left: unit.Dp(4), Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.X = gtx.Dp(unit.Dp(40)) + return item.onStatusCodeEditor.Layout(gtx, theme) + }) + }), + widgets.DrawLineFlex(theme.TableBorderColor, unit.Dp(35), unit.Dp(1)), + } + + itemType := item.fromDropDown.GetSelected().Identifier + if itemType == string(domain.VariableFromBody) { + items = append(items, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Left: unit.Dp(4), Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + editor := material.Editor(theme.Material(), item.jsonPathCodeEditor, "e.g. $.data[0].name") + editor.SelectionColor = theme.TextSelectionColor + return editor.Layout(gtx) + }) + })) + } else { + items = append(items, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Left: unit.Dp(4), Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + editor := material.Editor(theme.Material(), item.sourceKeyEditor, "Source Key") + editor.SelectionColor = theme.TextSelectionColor + return editor.Layout(gtx) + }) + })) + } + + items = append(items, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + ib := widgets.IconButton{ + Icon: widgets.DeleteIcon, + Size: unit.Dp(18), + Color: theme.TextColor, + Clickable: &item.deleteButton, + } + return ib.Layout(gtx, theme) + })) + + return items +} + +func (f *Variables) layout(gtx layout.Context, theme *chapartheme.Theme) layout.Dimensions { + border := widget.Border{ + Color: theme.TableBorderColor, + CornerRadius: unit.Dp(4), + Width: unit.Dp(1), + } + + return border.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + if len(f.Items) == 0 { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, material.Label(theme.Material(), unit.Sp(14), "No items").Layout) + } + + return material.List(theme.Material(), f.list).Layout(gtx, len(f.Items), func(gtx layout.Context, i int) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return f.itemLayout(gtx, theme, f.Items[i]) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + // only if it's not the last item + if i == len(f.Items)-1 { + return layout.Dimensions{} + } + return widgets.DrawLine(gtx, theme.TableBorderColor, unit.Dp(1), unit.Dp(gtx.Constraints.Max.X)) + }), + ) + }) + }) +} + +func (f *Variables) Layout(gtx layout.Context, title, hint string, theme *chapartheme.Theme) layout.Dimensions { + for i, field := range f.Items { + if field.deleteButton.Clicked(gtx) { + f.Items = append(f.Items[:i], f.Items[i+1:]...) + f.triggerChanged() + } + } + + inset := layout.Inset{Top: unit.Dp(15), Right: unit.Dp(10)} + prevInset := layout.Inset{Top: unit.Dp(8), Bottom: unit.Dp(4)} + return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle, Spacing: layout.SpaceBetween}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return material.Label(theme.Material(), theme.TextSize, title).Layout(gtx) + }), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Left: unit.Dp(10), + Right: unit.Dp(10), + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.Label(theme.Material(), unit.Sp(10), hint).Layout(gtx) + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Inset{ + Top: 0, + Bottom: unit.Dp(10), + Left: 0, + }.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + f.addButton.BackgroundColor = theme.Palette.Bg + f.addButton.Color = theme.TextColor + return f.addButton.Layout(gtx, theme) + }) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return f.layout(gtx, theme) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return prevInset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.Label(theme.Material(), theme.TextSize, "Preview:").Layout(gtx) + }) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return prevInset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return material.Label(theme.Material(), theme.TextSize, f.preview).Layout(gtx) + }) + }), + ) + }) +} diff --git a/ui/pages/requests/restful/request.go b/ui/pages/requests/restful/request.go index 9025161d..8885cb6b 100644 --- a/ui/pages/requests/restful/request.go +++ b/ui/pages/requests/restful/request.go @@ -17,17 +17,17 @@ type Request struct { PreRequest *component.PrePostRequest PostRequest *component.PrePostRequest - Body *Body - Params *Params - Headers *Headers - Auth *component.Auth + Body *Body + Params *Params + Headers *Headers + Variables *component.Variables + Auth *component.Auth currentTab string OnTabChange func(title string) } func NewRequest(req *domain.Request, explorer *explorer.Explorer, theme *chapartheme.Theme) *Request { - postRequestDropDown := widgets.NewDropDown( theme, widgets.NewDropDownOption("From Response").WithValue(domain.PostRequestSetFromResponseBody), @@ -41,6 +41,7 @@ func NewRequest(req *domain.Request, explorer *explorer.Explorer, theme *chapart {Title: "Body"}, {Title: "Auth"}, {Title: "Headers"}, + {Title: "Variables"}, {Title: "Pre Request"}, {Title: "Post Request"}, }, nil), @@ -59,10 +60,11 @@ func NewRequest(req *domain.Request, explorer *explorer.Explorer, theme *chapart // {Title: "Shell Script", Value: domain.PostRequestTypeShellScript, Type: component.TypeScript, Hint: "Write your post request shell script here"}, }, postRequestDropDown, theme), - Body: NewBody(req.Spec.HTTP.Request.Body, theme, explorer), - Params: NewParams(nil, nil), - Headers: NewHeaders(nil), - Auth: component.NewAuth(req.Spec.HTTP.Request.Auth, theme), + Body: NewBody(req.Spec.HTTP.Request.Body, theme, explorer), + Params: NewParams(nil, nil), + Headers: NewHeaders(nil), + Auth: component.NewAuth(req.Spec.HTTP.Request.Auth, theme), + Variables: component.NewVariables(theme), } if req.Spec != (domain.RequestSpec{}) && req.Spec.HTTP != nil && req.Spec.HTTP.Request != nil { @@ -117,6 +119,8 @@ func (r *Request) Layout(gtx layout.Context, theme *chapartheme.Theme) layout.Di return r.Headers.Layout(gtx, theme) case "Auth": return r.Auth.Layout(gtx, theme) + case "Variables": + return r.Variables.Layout(gtx, "Variables", "", theme) case "Body": return r.Body.Layout(gtx, theme) default: diff --git a/ui/widgets/checkbox.go b/ui/widgets/checkbox.go index ecc56957..33dbfca4 100644 --- a/ui/widgets/checkbox.go +++ b/ui/widgets/checkbox.go @@ -19,8 +19,8 @@ func CheckBox(th *material.Theme, checkBox *widget.Bool, label string) CheckBoxS Label: label, Color: th.Palette.Fg, IconColor: th.Palette.ContrastBg, - TextSize: th.TextSize * 14.0 / 16.0, - Size: 26, + TextSize: th.TextSize * 12.0 / 14.0, + Size: 24, shaper: th.Shaper, checkedStateIcon: th.Icon.CheckBoxChecked, uncheckedStateIcon: th.Icon.CheckBoxUnchecked, diff --git a/ui/widgets/numeric_editor.go b/ui/widgets/numeric_editor.go new file mode 100644 index 00000000..f77d8c19 --- /dev/null +++ b/ui/widgets/numeric_editor.go @@ -0,0 +1,43 @@ +package widgets + +import ( + "strconv" + + "gioui.org/layout" + "gioui.org/widget" + "gioui.org/widget/material" + + "github.com/chapar-rest/chapar/ui/chapartheme" +) + +type NumericEditor struct { + widget.Editor +} + +func (n *NumericEditor) Value() int { + v, _ := strconv.Atoi(n.Text()) + return v +} + +func (n *NumericEditor) Layout(gtx layout.Context, theme *chapartheme.Theme) layout.Dimensions { + for { + event, ok := n.Update(gtx) + if !ok { + break + } + + switch event.(type) { + // on change event + case widget.ChangeEvent: + if n.Text() != "" { + if _, err := strconv.Atoi(n.Text()); err != nil { + n.SetText(n.Text()[:len(n.Text())-1]) + } + } + } + } + + editor := material.Editor(theme.Material(), &n.Editor, "0") + editor.SelectionColor = theme.TextSelectionColor + return editor.Layout(gtx) +}