diff --git a/widgets/component/component.go b/widgets/component/component.go index 43142f77d..686177234 100644 --- a/widgets/component/component.go +++ b/widgets/component/component.go @@ -105,7 +105,7 @@ func (dsl *DSL) Parse() { for key, val := range BackendOnlyProps[t] { if dsl.Props.Has(key) { for k, v := range val { - dsl.Props[k] = v + dsl.Props[k] = dsl.copy(v) } } } @@ -127,3 +127,30 @@ func (dsl *DSL) Clone() *DSL { } return &new } + +// Copy the component properties +func (dsl *DSL) copy(v interface{}) interface{} { + var res interface{} = nil + switch v.(type) { + case map[string]interface{}: + // Clone the map + new := map[string]interface{}{} + for k1, v1 := range v.(map[string]interface{}) { + new[k1] = v1 + } + res = new + + case []interface{}: + // Clone the array + new := []interface{}{} + for _, v1 := range v.([]interface{}) { + new = append(new, dsl.copy(v1)) + } + res = new + + default: + res = v + } + + return res +} diff --git a/widgets/component/process.go b/widgets/component/process.go index 7ad0b3e01..2216d9140 100644 --- a/widgets/component/process.go +++ b/widgets/component/process.go @@ -2,120 +2,292 @@ package component import ( "fmt" + "regexp" "strings" jsoniter "github.com/json-iterator/go" "github.com/yaoapp/gou/model" "github.com/yaoapp/gou/process" - "github.com/yaoapp/kun/any" + "github.com/yaoapp/gou/query" "github.com/yaoapp/kun/exception" - "github.com/yaoapp/kun/utils" ) +var varRe = regexp.MustCompile(`\[\[\s*\$([A-Za-z0-9_\-]+)\s*\]\]`) + +// QueryProp query prop +type QueryProp struct { + Engine string `json:"engine"` + From string `json:"from"` + LabelField string `json:"labelField,omitempty"` + ValueField string `json:"valueField,omitempty"` + IconField string `json:"iconField,omitempty"` + LabelFormat string `json:"labelFormat,omitempty"` + ValueFormat string `json:"valueFormat,omitempty"` + IconFormat string `json:"iconFormat,omitempty"` + Wheres []map[string]interface{} `json:"wheres,omitempty"` + param model.QueryParam + dsl map[string]interface{} + props map[string]interface{} +} + +// Option select option +type Option struct { + Label string `json:"label"` + Value interface{} `json:"value"` + Icon string `json:"icon,omitempty"` +} + // Export process func exportProcess() { process.Register("yao.component.getoptions", processGetOptions) - process.Register("yao.component.selectoptions", processSelectOptions) + process.Register("yao.component.selectoptions", processSelectOptions) // Deprecated } // processGetOptions get options func processGetOptions(process *process.Process) interface{} { - utils.Dump(process.Args) - return []map[string]interface{}{ - {"label": "Option 1", "value": "1"}, - {"label": "Option 2", "value": "2"}, + + process.ValidateArgNums(2) + params := process.ArgsMap(0, map[string]interface{}{}) + props := process.ArgsMap(1, map[string]interface{}{}) + + // Paser props + p, err := parseOptionsProps(params, props) + if err != nil { + exception.New(err.Error(), 400).Throw() + } + + // Query the data + options := []Option{} + if p.Engine != "" { + engine, err := query.Select(p.Engine) + if err != nil { + exception.New(err.Error(), 400).Throw() + } + + qb, err := engine.Load(p.dsl) + if err != nil { + exception.New(err.Error(), 400).Throw() + } + + // Query the data + data := qb.Get(nil) + for _, row := range data { + p.format(&options, row) + } + + return options + } + + // Query param + m := model.Select(p.From) + data, err := m.Get(p.param) + if err != nil { + exception.New(err.Error(), 500).Throw() } + + // Format the data + for _, row := range data { + p.format(&options, row) + } + + return options } -func processSelectOptions(process *process.Process) interface{} { - process.ValidateArgNums(1) - query := process.ArgsMap(0, map[string]interface{}{}) - if !query.Has("model") { - exception.New("query.model required", 400).Throw() +// parseOptionsProps parse options props +func parseOptionsProps(query, props map[string]interface{}) (*QueryProp, error) { + if props["query"] == nil { + exception.New("props.query is required", 400).Throw() } - modelName, ok := query.Get("model").(string) - if !ok { - exception.New("query.model must be a string", 400).Throw() + // Read props + if v, ok := props["query"].(map[string]interface{}); ok { + props = v } - m := model.Select(modelName) + // Read query condition + var keywords string = "" + var selected interface{} = nil + if v, ok := query["keywords"].(string); ok { + keywords = v + } + if v, ok := query["selected"]; ok { + selected = v + } - valueField := query.Get("value") - if valueField == nil { - valueField = "id" + raw, err := jsoniter.Marshal(props) + if err != nil { + return nil, err } - value, ok := valueField.(string) - if !ok { - exception.New("query.value must be a string", 400).Throw() + + qprops := QueryProp{} + err = jsoniter.Unmarshal(raw, &qprops) + if err != nil { + return nil, err } - labelField := query.Get("label") - if labelField == nil { - labelField = "name" + qprops.props = props + err = qprops.parse(keywords, selected) + if err != nil { + return nil, err } - label, ok := labelField.(string) - if !ok { - exception.New("query.label must be a string", 400).Throw() + return &qprops, nil +} + +// format format the option +func (q *QueryProp) format(options *[]Option, row map[string]interface{}) { + label := row[q.LabelField] + value := row[q.ValueField] + option := Option{Label: fmt.Sprintf("%v", label), Value: value} + if q.IconField != "" { + option.Icon = fmt.Sprintf("%v", row[q.IconField]) } - limit := 500 - if query.Get("limit") != nil { - v := any.Of(query.Get("limit")) - if v.IsInt() || v.IsString() { - limit = v.CInt() - } + if q.LabelFormat != "" { + option.Label = q.replaceString(q.LabelFormat, row) } - wheres := []model.QueryWhere{} - switch input := query.Get("wheres").(type) { - case string: - where := model.QueryWhere{} - err := jsoniter.Unmarshal([]byte(input), &where) - if err != nil { - exception.New("query.wheres error %s", 400, err.Error()).Throw() + if q.ValueFormat != "" { + option.Value = q.replaceString(q.ValueFormat, row) + } + + if q.IconField != "" && q.IconFormat != "" { + option.Icon = q.replaceString(q.IconFormat, row) + } + + // Update the option + *options = append(*options, option) +} + +func (q *QueryProp) parse(keywords string, selected interface{}) error { + if q.Wheres == nil { + q.Wheres = []map[string]interface{}{} + } + + // Validate the query param required fields + if q.Engine == "" { + if q.From == "" { + return fmt.Errorf("props.from is required") } - wheres = append(wheres, where) - break - - case []string: - for _, data := range input { - where := model.QueryWhere{} - err := jsoniter.Unmarshal([]byte(data), &where) - if err != nil { - exception.New("query.wheres error %s", 400, err.Error()).Throw() - } + if q.LabelField == "" { + return fmt.Errorf("props.labelField is required") + } + if q.ValueField == "" { + return fmt.Errorf("props.valueField is required") + } + } + + // Parse wheres + wheres := []map[string]interface{}{} + for _, where := range q.Wheres { + if q.replaceWhere(where, map[string]interface{}{"KEYWORDS": keywords, "SELECTED": selected}) { wheres = append(wheres, where) } - break } - if data, ok := query.Get("wheres").(string); ok { - data = strings.TrimSpace(data) - if strings.HasPrefix(data, "{") && strings.HasSuffix(data, "}") { - data = fmt.Sprintf("[%s]", data) + // Update the props + props := map[string]interface{}{} + for key, value := range q.props { + props[key] = value + } + props["wheres"] = wheres + + // Return the query dsl, if the engine is set + if q.Engine != "" { + + if q.LabelField == "" { + q.LabelField = "label" } - err := jsoniter.Unmarshal([]byte(data), &wheres) - if err != nil { - exception.New("query.wheres error %s", 400, err.Error()).Throw() + + if q.ValueField == "" { + q.ValueField = "value" } + + if q.IconField == "" { + q.IconField = "icon" + } + + q.dsl = props + return nil + } + + // Parse the query param from the props + q.param = model.QueryParam{ + Model: q.From, + Select: []interface{}{q.LabelField, q.ValueField}, } - rows, err := m.Get(model.QueryParam{ - Select: []interface{}{valueField, labelField}, - Wheres: wheres, - Limit: limit, - }) + if q.IconField != "" { + q.param.Select = append(q.param.Select, q.IconField) + } + + raw, err := jsoniter.Marshal(props) if err != nil { - exception.New("%s", 500, err.Error()).Throw() + return err } - res := []map[string]interface{}{} - for _, row := range rows { - res = append(res, map[string]interface{}{ - "label": row.Get(label), - "value": row.Get(value), - }) + err = jsoniter.Unmarshal(raw, &q.param) + if err != nil { + return err } - return res + + return nil +} + +func (q *QueryProp) replaceString(format string, data map[string]interface{}) string { + if data == nil { + return format + } + + matches := varRe.FindAllStringSubmatch(format, -1) + if len(matches) > 0 { + for _, match := range matches { + name := match[1] + orignal := match[0] + if val, ok := data[name]; ok { + format = strings.ReplaceAll(format, orignal, fmt.Sprintf("%v", val)) + } + } + } + + return format +} + +// Replace replace the query where condition +// return true if the where condition is effective, otherwise return false +func (q *QueryProp) replaceWhere(where map[string]interface{}, data map[string]interface{}) bool { + if where == nil { + return false + } + + for key, value := range where { + if v, ok := value.(string); ok { + matches := varRe.FindAllStringSubmatch(v, -1) + if len(matches) > 0 { + name := matches[0][1] + if val, ok := data[name]; ok { + + // Check if the value is empty + if val == nil || val == "" { + return false + } + + // Replace the value + where[key] = val + return true + } + return false + } + } + } + + return true +} + +// Deprecated: please use processGetOptions instead +// This function may cause security issue, please use processGetOptions instead +// It will be removed when the v0.10.4 released +func processSelectOptions(process *process.Process) interface{} { + message := "process yao.component.SelectOptions is deprecated, please use yao.component.GetOptions instead" + exception.New(message, 400).Throw() + return nil } diff --git a/widgets/component/process_test.go b/widgets/component/process_test.go new file mode 100644 index 000000000..5506cc258 --- /dev/null +++ b/widgets/component/process_test.go @@ -0,0 +1,225 @@ +package component + +import ( + "testing" + + jsoniter "github.com/json-iterator/go" + "github.com/stretchr/testify/assert" + "github.com/yaoapp/gou/process" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/test" +) + +func TestProcessGetOptions(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + props := prepare(t) + + name := "yao.component.GetOptions" + for _, queryParam := range props { + + args := []interface{}{ + map[string]interface{}{}, + map[string]interface{}{"query": queryParam}, + } + + p, err := process.Of(name, args...) + if err != nil { + t.Fatal(err) + } + + err = p.Execute() + if err != nil { + t.Fatal(err) + } + defer p.Release() + res, ok := p.Value().([]Option) + if !ok { + t.Fatal("Result is not []Option") + } + + if len(res) != 8 { + t.Fatal("Result length is not 8") + } + + assert.Equal(t, "Category cat 1-active-1", res[0].Label) + assert.Equal(t, "1", res[0].Value) + assert.Equal(t, "active-1", res[0].Icon) + + // With KEYWORDS + args = []interface{}{ + map[string]interface{}{"keywords": "dog"}, + map[string]interface{}{"query": queryParam}, + } + + p, err = process.Of(name, args...) + if err != nil { + t.Fatal(err) + } + + err = p.Execute() + if err != nil { + t.Fatal(err) + } + + res, ok = p.Value().([]Option) + if !ok { + t.Fatal("Result is not []Option") + } + + if len(res) != 2 { + t.Fatal("Result length is not 2") + } + + assert.Equal(t, "Category dog 7-active-7", res[0].Label) + assert.Equal(t, "7", res[0].Value) + assert.Equal(t, "active-7", res[0].Icon) + + // With SELECTED + args = []interface{}{ + map[string]interface{}{"selected": []interface{}{1}}, + map[string]interface{}{"query": queryParam}, + } + + p, err = process.Of(name, args...) + if err != nil { + t.Fatal(err) + } + + err = p.Execute() + if err != nil { + t.Fatal(err) + } + res, ok = p.Value().([]Option) + if !ok { + t.Fatal("Result is not []Option") + } + + if len(res) != 1 { + t.Fatal("Result length is not 1") + } + + assert.Equal(t, "Category cat 1-active-1", res[0].Label) + assert.Equal(t, "1", res[0].Value) + assert.Equal(t, "active-1", res[0].Icon) + + // With KEYWORDS and SELECTED + args = []interface{}{ + map[string]interface{}{"keywords": "dog", "selected": []interface{}{1, 2}}, + map[string]interface{}{"query": queryParam}, + } + + p, err = process.Of(name, args...) + if err != nil { + t.Fatal(err) + } + + err = p.Execute() + if err != nil { + t.Fatal(err) + } + + res, ok = p.Value().([]Option) + if !ok { + t.Fatal("Result is not []Option") + } + + if len(res) != 4 { + t.Fatal("Result length is not 4") + } + + assert.Equal(t, "Category cat 1-active-1", res[0].Label) + assert.Equal(t, "1", res[0].Value) + assert.Equal(t, "active-1", res[0].Icon) + assert.Equal(t, "Category dog 7-active-7", res[2].Label) + assert.Equal(t, "7", res[2].Value) + assert.Equal(t, "active-7", res[2].Icon) + } +} + +func TestProcessSelectOptions(t *testing.T) { + + name := "yao.component.SelectOptions" + + p, err := process.Of(name, nil) + if err != nil { + t.Fatal(err) + } + + err = p.Execute() + assert.Contains(t, err.Error(), "process yao.component.SelectOptions is deprecated, please use yao.component.GetOptions instead") +} + +func prepare(t *testing.T) map[string]map[string]interface{} { + exportProcess() + + // Prepare data for testing + err := process.New("models.category.Migrate", true).Execute() + if err != nil { + t.Fatal(err) + } + + err = process.New("models.category.Insert", + []string{"name", "status"}, + [][]interface{}{ + {"Category cat 1", "active-1"}, + {"Category cat 2", "active-2"}, + {"Category cat 3", "active-3"}, + {"Category cat 4", "active-4"}, + {"Category cat 5", "active-5"}, + {"Category cat 6", "active-6"}, + {"Category dog 7", "active-7"}, + {"Category dog 8", "active-8"}, + }).Execute() + if err != nil { + t.Fatal(err) + } + + queryParam := map[string]interface{}{} + queryDSL := map[string]interface{}{} + err = jsoniter.Unmarshal([]byte(`{ + "labelField": "name", + "valueField": "id", + "iconField": "status", + "from": "category", + "wheres": [ + { "column": "name", "value": "[[ $KEYWORDS ]]", "op": "match" }, + { + "method": "orwhere", + "column": "id", + "op": "in", + "value": "[[ $SELECTED ]]" + } + ], + "limit": 20, + "labelFormat": "[[ $name ]]-[[ $status ]]", + "valueFormat": "[[ $id ]]", + "iconFormat": "[[ $status ]]" + }`), &queryParam) + + if err != nil { + t.Fatal(err) + } + + err = jsoniter.Unmarshal([]byte(`{ + "engine": "query-test", + "select": ["name as label", "id as value", "status as icon"], + "from": "category", + "wheres": [ + { "field": "name", "match": "[[ $KEYWORDS ]]" }, + { "or": true, "field":"id", "in":"[[ $SELECTED ]]" } + ], + "limit": 20, + "labelFormat": "[[ $label ]]-[[ $icon ]]", + "valueFormat": "[[ $value ]]", + "iconFormat": "[[ $icon ]]" + }`), &queryDSL) + if err != nil { + t.Fatal(err) + } + + return map[string]map[string]interface{}{ + "queryParam": queryParam, + "queryDSL": queryDSL, + } +} diff --git a/widgets/dashboard/dashboard_test.go b/widgets/dashboard/dashboard_test.go index d9d7b9475..1a4e6cf40 100644 --- a/widgets/dashboard/dashboard_test.go +++ b/widgets/dashboard/dashboard_test.go @@ -8,6 +8,7 @@ import ( "github.com/yaoapp/yao/flow" "github.com/yaoapp/yao/i18n" "github.com/yaoapp/yao/test" + "github.com/yaoapp/yao/widgets/component" ) func TestLoad(t *testing.T) { @@ -40,4 +41,6 @@ func prepare(t *testing.T, language ...string) { if err != nil { t.Fatal(err) } + + component.Export() } diff --git a/widgets/dashboard/process_test.go b/widgets/dashboard/process_test.go index 8ac38a9c7..cd597ce58 100644 --- a/widgets/dashboard/process_test.go +++ b/widgets/dashboard/process_test.go @@ -1,7 +1,6 @@ package dashboard import ( - "fmt" "net/url" "testing" @@ -10,9 +9,9 @@ import ( "github.com/yaoapp/gou/process" "github.com/yaoapp/gou/session" "github.com/yaoapp/kun/any" - "github.com/yaoapp/kun/maps" "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/test" + "github.com/yaoapp/yao/widgets/component" ) func TestProcessData(t *testing.T) { @@ -33,12 +32,12 @@ func TestProcessComponent(t *testing.T) { test.Prepare(t, config.Conf) defer test.Clean() prepare(t) + testData(t) args := []interface{}{ "workspace", "fields.filter.状态.edit.props.xProps", "remote", - map[string]interface{}{"select": []string{"name", "status"}, "limit": 2}, } res, err := process.New("yao.dashboard.Component", args...).Exec() @@ -46,15 +45,13 @@ func TestProcessComponent(t *testing.T) { t.Fatal(err) } - fmt.Println(res) - - pets, ok := res.([]maps.MapStr) + pets, ok := res.([]component.Option) assert.True(t, ok) assert.Equal(t, 2, len(pets)) - assert.Equal(t, "Cookie", pets[0]["name"]) - assert.Equal(t, "checked", pets[0]["status"]) - assert.Equal(t, "Baby", pets[1]["name"]) - assert.Equal(t, "checked", pets[1]["status"]) + assert.Equal(t, "Cookie", pets[0].Label) + assert.Equal(t, "checked", pets[0].Value) + assert.Equal(t, "Baby", pets[1].Label) + assert.Equal(t, "checked", pets[1].Value) args = []interface{}{ "workspace", diff --git a/widgets/table/process_test.go b/widgets/table/process_test.go index 1f7c283c9..883e0886f 100644 --- a/widgets/table/process_test.go +++ b/widgets/table/process_test.go @@ -17,6 +17,7 @@ import ( "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/helper" "github.com/yaoapp/yao/test" + "github.com/yaoapp/yao/widgets/component" ) func TestProcessSearch(t *testing.T) { @@ -364,13 +365,6 @@ func TestProcessComponent(t *testing.T) { "pet", "fields.filter.状态.edit.props.xProps", "remote", - map[string]interface{}{ - "model": "pet", - "label": "name", - "value": "status", - "wheres[]": `{"column":"id","op":"ge","value":0}`, - "limit": "2", - }, } res, err := process.New("yao.table.Component", args...).Exec() @@ -378,13 +372,13 @@ func TestProcessComponent(t *testing.T) { t.Fatal(err) } - pets, ok := res.([]map[string]interface{}) + pets, ok := res.([]component.Option) assert.True(t, ok) assert.Equal(t, 2, len(pets)) - assert.Equal(t, "Cookie", pets[0]["label"]) - assert.Equal(t, "checked", pets[0]["value"]) - assert.Equal(t, "Baby", pets[1]["label"]) - assert.Equal(t, "checked", pets[1]["value"]) + assert.Equal(t, "Cookie", pets[0].Label) + assert.Equal(t, "checked", pets[0].Value) + assert.Equal(t, "Baby", pets[1].Label) + assert.Equal(t, "checked", pets[1].Value) } func TestProcessComponentError(t *testing.T) { @@ -525,7 +519,7 @@ func TestProcessXgenWithPermissions(t *testing.T) { session.Global().Set("__permissions", map[string]interface{}{ "tables.pet": []string{ "8ca9bdf0fa2cbc8f1018f8566ed6ab5e", // fields.table.消费金额 - "f03f1ae60c46dd6cdeda87b919a51d7e", // fields.filter.状态 + "c5b1f06582e1dff3ac6d16822fdadd54", // fields.filter.状态 "b1483ade34cd51261817558114e74e3f", // filter.actions[0] 添加宠物 "e6a67850312980e8372e550c5b361097", // operation.actions[0] 查看 },