]> source.dussan.org Git - gitea.git/commitdiff
Improve template helper functions: string/slice (#24266)
authorwxiaoguang <wxiaoguang@gmail.com>
Sat, 22 Apr 2023 18:16:22 +0000 (02:16 +0800)
committerGitHub <noreply@github.com>
Sat, 22 Apr 2023 18:16:22 +0000 (14:16 -0400)
Follow #23328

The improvements:

1. The `contains` functions are covered by tests
2. The inconsistent behavior of `containGeneric` is replaced by
`StringUtils.Contains` and `SliceUtils.Contains`
3. In the future we can move more help functions into XxxUtils to
simplify the `helper.go` and reduce unnecessary global functions.

FAQ:

1. Why it's called `StringUtils.Contains` but not `strings.Contains`
like Golang?

Because our `StringUtils` is not Golang's `strings` package. There will
be our own string functions.

---------

Co-authored-by: silverwind <me@silverwind.io>
12 files changed:
modules/templates/helper.go
modules/templates/util.go [deleted file]
modules/templates/util_dict.go [new file with mode: 0644]
modules/templates/util_slice.go [new file with mode: 0644]
modules/templates/util_string.go [new file with mode: 0644]
modules/templates/util_test.go
templates/repo/graph/commits.tmpl
templates/repo/header.tmpl
templates/repo/issue/list.tmpl
templates/repo/issue/milestone_issues.tmpl
templates/repo/issue/view_content/attachments.tmpl
templates/repo/settings/tags.tmpl

index 42680827ebbf3c30fa3d608c2abe71e0d415ea6a..c5b77989be24eaa51e9ffdec947c8b26fc460094 100644 (file)
@@ -15,7 +15,6 @@ import (
        "mime"
        "net/url"
        "path/filepath"
-       "reflect"
        "regexp"
        "strings"
        "time"
@@ -68,11 +67,15 @@ func NewFuncMap() []template.FuncMap {
                "PathEscape":         url.PathEscape,
                "PathEscapeSegments": util.PathEscapeSegments,
 
+               // utils
+               "StringUtils": NewStringUtils,
+               "SliceUtils":  NewSliceUtils,
+
                // -----------------------------------------------------------------
                // string / json
+               // TODO: move string helper functions to StringUtils
                "Join":           strings.Join,
                "DotEscape":      DotEscape,
-               "HasPrefix":      strings.HasPrefix,
                "EllipsisString": base.EllipsisString,
                "DumpVar":        dumpVar,
 
@@ -144,35 +147,6 @@ func NewFuncMap() []template.FuncMap {
                        return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
                },
 
-               // -----------------------------------------------------------------
-               // slice
-               "containGeneric": func(arr, v interface{}) bool {
-                       arrV := reflect.ValueOf(arr)
-                       if arrV.Kind() == reflect.String && reflect.ValueOf(v).Kind() == reflect.String {
-                               return strings.Contains(arr.(string), v.(string))
-                       }
-                       if arrV.Kind() == reflect.Slice {
-                               for i := 0; i < arrV.Len(); i++ {
-                                       iV := arrV.Index(i)
-                                       if !iV.CanInterface() {
-                                               continue
-                                       }
-                                       if iV.Interface() == v {
-                                               return true
-                                       }
-                               }
-                       }
-                       return false
-               },
-               "contain": func(s []int64, id int64) bool {
-                       for i := 0; i < len(s); i++ {
-                               if s[i] == id {
-                                       return true
-                               }
-                       }
-                       return false
-               },
-
                // -----------------------------------------------------------------
                // setting
                "AppName": func() string {
diff --git a/modules/templates/util.go b/modules/templates/util.go
deleted file mode 100644 (file)
index c83f224..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright 2023 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package templates
-
-import (
-       "fmt"
-       "html"
-       "html/template"
-       "reflect"
-
-       "code.gitea.io/gitea/modules/json"
-       "code.gitea.io/gitea/modules/setting"
-)
-
-func dictMerge(base map[string]any, arg any) bool {
-       if arg == nil {
-               return true
-       }
-       rv := reflect.ValueOf(arg)
-       if rv.Kind() == reflect.Map {
-               for _, k := range rv.MapKeys() {
-                       base[k.String()] = rv.MapIndex(k).Interface()
-               }
-               return true
-       }
-       return false
-}
-
-// dict is a helper function for creating a map[string]any from a list of key-value pairs.
-// If the key is dot ".", the value is merged into the base map, just like Golang template's dot syntax: dot means current
-// The dot syntax is highly discouraged because it might cause unclear key conflicts. It's always good to use explicit keys.
-func dict(args ...any) (map[string]any, error) {
-       if len(args)%2 != 0 {
-               return nil, fmt.Errorf("invalid dict constructor syntax: must have key-value pairs")
-       }
-       m := make(map[string]any, len(args)/2)
-       for i := 0; i < len(args); i += 2 {
-               key, ok := args[i].(string)
-               if !ok {
-                       return nil, fmt.Errorf("invalid dict constructor syntax: unable to merge args[%d]", i)
-               }
-               if key == "." {
-                       if ok = dictMerge(m, args[i+1]); !ok {
-                               return nil, fmt.Errorf("invalid dict constructor syntax: dot arg[%d] must be followed by a dict", i)
-                       }
-               } else {
-                       m[key] = args[i+1]
-               }
-       }
-       return m, nil
-}
-
-func dumpVarMarshalable(v any, dumped map[uintptr]bool) (ret any, ok bool) {
-       if v == nil {
-               return nil, true
-       }
-       e := reflect.ValueOf(v)
-       for e.Kind() == reflect.Pointer {
-               e = e.Elem()
-       }
-       if e.CanAddr() {
-               addr := e.UnsafeAddr()
-               if dumped[addr] {
-                       return "[dumped]", false
-               }
-               dumped[addr] = true
-               defer delete(dumped, addr)
-       }
-       switch e.Kind() {
-       case reflect.Bool, reflect.String,
-               reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
-               reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
-               reflect.Float32, reflect.Float64:
-               return e.Interface(), true
-       case reflect.Struct:
-               m := map[string]any{}
-               for i := 0; i < e.NumField(); i++ {
-                       k := e.Type().Field(i).Name
-                       if !e.Type().Field(i).IsExported() {
-                               continue
-                       }
-                       v := e.Field(i).Interface()
-                       m[k], _ = dumpVarMarshalable(v, dumped)
-               }
-               return m, true
-       case reflect.Map:
-               m := map[string]any{}
-               for _, k := range e.MapKeys() {
-                       m[k.String()], _ = dumpVarMarshalable(e.MapIndex(k).Interface(), dumped)
-               }
-               return m, true
-       case reflect.Array, reflect.Slice:
-               var m []any
-               for i := 0; i < e.Len(); i++ {
-                       v, _ := dumpVarMarshalable(e.Index(i).Interface(), dumped)
-                       m = append(m, v)
-               }
-               return m, true
-       default:
-               return "[" + reflect.TypeOf(v).String() + "]", false
-       }
-}
-
-// dumpVar helps to dump a variable in a template, to help debugging and development.
-func dumpVar(v any) template.HTML {
-       if setting.IsProd {
-               return "<pre>dumpVar: only available in dev mode</pre>"
-       }
-       m, ok := dumpVarMarshalable(v, map[uintptr]bool{})
-       dumpStr := ""
-       jsonBytes, err := json.MarshalIndent(m, "", "  ")
-       if err != nil {
-               dumpStr = fmt.Sprintf("dumpVar: unable to marshal %T: %v", v, err)
-       } else if ok {
-               dumpStr = fmt.Sprintf("dumpVar: %T\n%s", v, string(jsonBytes))
-       } else {
-               dumpStr = fmt.Sprintf("dumpVar: unmarshalable %T\n%s", v, string(jsonBytes))
-       }
-       return template.HTML("<pre>" + html.EscapeString(dumpStr) + "</pre>")
-}
diff --git a/modules/templates/util_dict.go b/modules/templates/util_dict.go
new file mode 100644 (file)
index 0000000..c83f224
--- /dev/null
@@ -0,0 +1,121 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+       "fmt"
+       "html"
+       "html/template"
+       "reflect"
+
+       "code.gitea.io/gitea/modules/json"
+       "code.gitea.io/gitea/modules/setting"
+)
+
+func dictMerge(base map[string]any, arg any) bool {
+       if arg == nil {
+               return true
+       }
+       rv := reflect.ValueOf(arg)
+       if rv.Kind() == reflect.Map {
+               for _, k := range rv.MapKeys() {
+                       base[k.String()] = rv.MapIndex(k).Interface()
+               }
+               return true
+       }
+       return false
+}
+
+// dict is a helper function for creating a map[string]any from a list of key-value pairs.
+// If the key is dot ".", the value is merged into the base map, just like Golang template's dot syntax: dot means current
+// The dot syntax is highly discouraged because it might cause unclear key conflicts. It's always good to use explicit keys.
+func dict(args ...any) (map[string]any, error) {
+       if len(args)%2 != 0 {
+               return nil, fmt.Errorf("invalid dict constructor syntax: must have key-value pairs")
+       }
+       m := make(map[string]any, len(args)/2)
+       for i := 0; i < len(args); i += 2 {
+               key, ok := args[i].(string)
+               if !ok {
+                       return nil, fmt.Errorf("invalid dict constructor syntax: unable to merge args[%d]", i)
+               }
+               if key == "." {
+                       if ok = dictMerge(m, args[i+1]); !ok {
+                               return nil, fmt.Errorf("invalid dict constructor syntax: dot arg[%d] must be followed by a dict", i)
+                       }
+               } else {
+                       m[key] = args[i+1]
+               }
+       }
+       return m, nil
+}
+
+func dumpVarMarshalable(v any, dumped map[uintptr]bool) (ret any, ok bool) {
+       if v == nil {
+               return nil, true
+       }
+       e := reflect.ValueOf(v)
+       for e.Kind() == reflect.Pointer {
+               e = e.Elem()
+       }
+       if e.CanAddr() {
+               addr := e.UnsafeAddr()
+               if dumped[addr] {
+                       return "[dumped]", false
+               }
+               dumped[addr] = true
+               defer delete(dumped, addr)
+       }
+       switch e.Kind() {
+       case reflect.Bool, reflect.String,
+               reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+               reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
+               reflect.Float32, reflect.Float64:
+               return e.Interface(), true
+       case reflect.Struct:
+               m := map[string]any{}
+               for i := 0; i < e.NumField(); i++ {
+                       k := e.Type().Field(i).Name
+                       if !e.Type().Field(i).IsExported() {
+                               continue
+                       }
+                       v := e.Field(i).Interface()
+                       m[k], _ = dumpVarMarshalable(v, dumped)
+               }
+               return m, true
+       case reflect.Map:
+               m := map[string]any{}
+               for _, k := range e.MapKeys() {
+                       m[k.String()], _ = dumpVarMarshalable(e.MapIndex(k).Interface(), dumped)
+               }
+               return m, true
+       case reflect.Array, reflect.Slice:
+               var m []any
+               for i := 0; i < e.Len(); i++ {
+                       v, _ := dumpVarMarshalable(e.Index(i).Interface(), dumped)
+                       m = append(m, v)
+               }
+               return m, true
+       default:
+               return "[" + reflect.TypeOf(v).String() + "]", false
+       }
+}
+
+// dumpVar helps to dump a variable in a template, to help debugging and development.
+func dumpVar(v any) template.HTML {
+       if setting.IsProd {
+               return "<pre>dumpVar: only available in dev mode</pre>"
+       }
+       m, ok := dumpVarMarshalable(v, map[uintptr]bool{})
+       dumpStr := ""
+       jsonBytes, err := json.MarshalIndent(m, "", "  ")
+       if err != nil {
+               dumpStr = fmt.Sprintf("dumpVar: unable to marshal %T: %v", v, err)
+       } else if ok {
+               dumpStr = fmt.Sprintf("dumpVar: %T\n%s", v, string(jsonBytes))
+       } else {
+               dumpStr = fmt.Sprintf("dumpVar: unmarshalable %T\n%s", v, string(jsonBytes))
+       }
+       return template.HTML("<pre>" + html.EscapeString(dumpStr) + "</pre>")
+}
diff --git a/modules/templates/util_slice.go b/modules/templates/util_slice.go
new file mode 100644 (file)
index 0000000..a3318cc
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import (
+       "fmt"
+       "reflect"
+)
+
+type SliceUtils struct{}
+
+func NewSliceUtils() *SliceUtils {
+       return &SliceUtils{}
+}
+
+func (su *SliceUtils) Contains(s, v any) bool {
+       if s == nil {
+               return false
+       }
+       sv := reflect.ValueOf(s)
+       if sv.Kind() != reflect.Slice && sv.Kind() != reflect.Array {
+               panic(fmt.Sprintf("invalid type, expected slice or array, but got: %T", s))
+       }
+       for i := 0; i < sv.Len(); i++ {
+               it := sv.Index(i)
+               if !it.CanInterface() {
+                       continue
+               }
+               if it.Interface() == v {
+                       return true
+               }
+       }
+       return false
+}
diff --git a/modules/templates/util_string.go b/modules/templates/util_string.go
new file mode 100644 (file)
index 0000000..e86bbe9
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package templates
+
+import "strings"
+
+type StringUtils struct{}
+
+func NewStringUtils() *StringUtils {
+       return &StringUtils{}
+}
+
+func (su *StringUtils) HasPrefix(s, prefix string) bool {
+       return strings.HasPrefix(s, prefix)
+}
+
+func (su *StringUtils) Contains(s, substr string) bool {
+       return strings.Contains(s, substr)
+}
index dfa691c5e2d6a74ac6c0d2a324a3411d5b05f5e4..febaf7fa88158ab09f5eb72896932214f3d925ac 100644 (file)
@@ -4,6 +4,9 @@
 package templates
 
 import (
+       "html/template"
+       "io"
+       "strings"
        "testing"
 
        "github.com/stretchr/testify/assert"
@@ -41,3 +44,36 @@ func TestDict(t *testing.T) {
                assert.Error(t, err)
        }
 }
+
+func TestUtils(t *testing.T) {
+       execTmpl := func(code string, data any) string {
+               tmpl := template.New("test")
+               tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
+               template.Must(tmpl.Parse(code))
+               w := &strings.Builder{}
+               assert.NoError(t, tmpl.Execute(w, data))
+               return w.String()
+       }
+
+       actual := execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "a"})
+       assert.Equal(t, "true", actual)
+
+       actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []string{"a", "b"}, "Value": "x"})
+       assert.Equal(t, "false", actual)
+
+       actual = execTmpl("{{SliceUtils.Contains .Slice .Value}}", map[string]any{"Slice": []int64{1, 2}, "Value": int64(2)})
+       assert.Equal(t, "true", actual)
+
+       actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "b"})
+       assert.Equal(t, "true", actual)
+
+       actual = execTmpl("{{StringUtils.Contains .String .Value}}", map[string]any{"String": "abc", "Value": "x"})
+       assert.Equal(t, "false", actual)
+
+       tmpl := template.New("test")
+       tmpl.Funcs(template.FuncMap{"SliceUtils": NewSliceUtils, "StringUtils": NewStringUtils})
+       template.Must(tmpl.Parse("{{SliceUtils.Contains .Slice .Value}}"))
+       // error is like this: `template: test:1:12: executing "test" at <SliceUtils.Contains>: error calling Contains: ...`
+       err := tmpl.Execute(io.Discard, map[string]any{"Slice": struct{}{}})
+       assert.ErrorContains(t, err, "invalid type, expected slice or array")
+}
index 4a01fefeddc1b3242b9f3b951b2a23af5e6ab4c9..44f63a7d76709d58a8a16d8fe2a93208fbb73a7a 100644 (file)
@@ -35,7 +35,7 @@
                                                {{range $commit.Refs}}
                                                        {{$refGroup := .RefGroup}}
                                                        {{if eq $refGroup "pull"}}
-                                                               {{if or (not $.HidePRRefs) (containGeneric $.SelectedBranches .Name)}}
+                                                               {{if or (not $.HidePRRefs) (SliceUtils.Contains $.SelectedBranches .Name)}}
                                                                        <!-- it's intended to use issues not pulls, if it's a pull you will get redirected -->
                                                                        <a class="ui labelled icon button basic tiny gt-mr-2" href="{{$.RepoLink}}/{{if $.Repository.UnitEnabled $.Context $.UnitTypePullRequests}}pulls{{else}}issues{{end}}/{{.ShortName|PathEscape}}">
                                                                                {{svg "octicon-git-pull-request" 16 "gt-mr-2"}}#{{.ShortName}}
index ef2549eb7cc02a61714bdd96bb5cb082aae1ec90..a999c73804f1c4457de4f563476b50c7946ad1a9 100644 (file)
                                {{end}}
 
                                {{if or (.Permission.CanRead $.UnitTypeWiki) (.Permission.CanRead $.UnitTypeExternalWiki)}}
-                                       <a class="{{if .PageIsWiki}}active {{end}}item" href="{{.RepoLink}}/wiki" {{if and (.Permission.CanRead $.UnitTypeExternalWiki) (not (HasPrefix ((.Repository.MustGetUnit $.Context $.UnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL) (.Repository.Link)))}} target="_blank" rel="noopener noreferrer" {{end}}>
+                                       <a class="{{if .PageIsWiki}}active {{end}}item" href="{{.RepoLink}}/wiki" {{if and (.Permission.CanRead $.UnitTypeExternalWiki) (not (StringUtils.HasPrefix ((.Repository.MustGetUnit $.Context $.UnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL) (.Repository.Link)))}} target="_blank" rel="noopener noreferrer" {{end}}>
                                                {{svg "octicon-book"}} {{.locale.Tr "repo.wiki"}}
                                        </a>
                                {{end}}
index 6cc390cd5e28e7e2640907074258a4e19d88b2f7..9c31262355be51a45b09eb9fe520886d42f84d44 100644 (file)
                                                                {{end}}
                                                                {{$previousExclusiveScope = $exclusiveScope}}
                                                                <div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels">
-                                                                       {{if contain $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel $.Context .}}
+                                                                       {{if SliceUtils.Contains $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel $.Context .}}
                                                                </div>
                                                        {{end}}
                                                </div>
index 1d55eb39cdc11b499e403df69508c77b97f970ed..137f2c2d9dda79649234ebe9e16defd2f6493e8e 100644 (file)
@@ -65,7 +65,7 @@
                                                        <span class="info">{{.locale.Tr "repo.issues.filter_label_exclude" | Safe}}</span>
                                                        <a class="item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}">{{.locale.Tr "repo.issues.filter_label_no_select"}}</a>
                                                        {{range .Labels}}
-                                                               <a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel $.Context .}}</a>
+                                                               <a class="item label-filter-item" href="{{$.Link}}?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}" data-label-id="{{.ID}}">{{if .IsExcluded}}{{svg "octicon-circle-slash"}}{{else if SliceUtils.Contains $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel $.Context .}}</a>
                                                        {{end}}
                                                </div>
                                        </div>
                                                <div class="menu">
                                                        {{range .Labels}}
                                                                <div class="item issue-action" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels">
-                                                                       {{if contain $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel $.Context .}}
+                                                                       {{if SliceUtils.Contains $.SelLabelIDs .ID}}{{svg "octicon-check"}}{{end}} {{RenderLabel $.Context .}}
                                                                </div>
                                                        {{end}}
                                                </div>
index 7a429c31ecc0d1a4e76c8add38b6c29658cdd13a..f342340fe474d3a9630547d95de4d0dc401e80a4 100644 (file)
@@ -8,7 +8,7 @@
                        <div class="gt-f1 gt-p-3">
                                <a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}" title='{{$.ctxData.locale.Tr "repo.issues.attachment.open_tab" .Name}}'>
                                        {{if FilenameIsImage .Name}}
-                                               {{if not (containGeneric $.Content .UUID)}}
+                                               {{if not (StringUtils.Contains $.Content .UUID)}}
                                                        {{$hasThumbnails = true}}
                                                {{end}}
                                                {{svg "octicon-file"}}
@@ -29,7 +29,7 @@
                <div class="ui small thumbnails">
                        {{- range .Attachments -}}
                                {{if FilenameIsImage .Name}}
-                                       {{if not (containGeneric $.Content .UUID)}}
+                                       {{if not (StringUtils.Contains $.Content .UUID)}}
                                        <a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}">
                                                <img alt="{{.Name}}" src="{{.DownloadURL}}" title='{{$.ctxData.locale.Tr "repo.issues.attachment.open_tab" .Name}}'>
                                        </a>
index 0ebaff6fd29fc60dea8e24cce441c557fd60e4b7..6387a20de6e6f8dd0c850b019a75bd0b19e3d267 100644 (file)
                                                                                        {{if or .AllowlistUserIDs (and $.Owner.IsOrganization .AllowlistTeamIDs)}}
                                                                                                {{$userIDs := .AllowlistUserIDs}}
                                                                                                {{range $.Users}}
-                                                                                                       {{if contain $userIDs .ID}}
+                                                                                                       {{if SliceUtils.Contains $userIDs .ID}}
                                                                                                                <a class="ui basic label" href="{{.HomeLink}}">{{avatar $.Context . 26}} {{.GetDisplayName}}</a>
                                                                                                        {{end}}
                                                                                                {{end}}
                                                                                                {{if $.Owner.IsOrganization}}
                                                                                                        {{$teamIDs := .AllowlistTeamIDs}}
                                                                                                        {{range $.Teams}}
-                                                                                                               {{if contain $teamIDs .ID}}
+                                                                                                               {{if SliceUtils.Contains $teamIDs .ID}}
                                                                                                                        <a class="ui basic label" href="{{$.Owner.OrganisationLink}}/teams/{{PathEscape .LowerName}}">{{.Name}}</a>
                                                                                                                {{end}}
                                                                                                        {{end}}