Selaa lähdekoodia

Create and link tag to application working

jnfrati 4 vuotta sitten
vanhempi
sitoutus
7d68ce7ba3

+ 54 - 0
api/server/handlers/project/create_tag.go

@@ -0,0 +1,54 @@
+package project
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateTagHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateTagHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateTagHandler {
+	return &CreateTagHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	newTag := &types.CreateTagRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, newTag); !ok {
+		fmt.Println("HERE")
+		return
+	}
+
+	tag, err := c.Config().Repo.Tag().CreateTag(&models.Tag{
+		Name:      newTag.Name,
+		Color:     newTag.Color,
+		ProjectID: project.ID,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	c.WriteResult(w, r, tag)
+}

+ 0 - 0
api/server/handlers/project/get_tags.go → api/server/handlers/project/list_tags.go


+ 2 - 20
api/server/handlers/release/create.go

@@ -118,10 +118,10 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	if request.Tags != nil {
-		tmpRelease, err := LinkTagsToRelease(c.Config(), request.Tags, release)
+		tags, err := c.Config().Repo.Tag().LinkTagsToRelease(request.Tags, release)
 
 		if err == nil {
-			release = tmpRelease
+			release.Tags = append(release.Tags, tags...)
 		}
 	}
 
@@ -411,21 +411,3 @@ func getGARunner(
 		Version:                "v0.1.0",
 	}, nil
 }
-
-// Gets a list of tags to be added into a release and then returns the updated release
-func LinkTagsToRelease(config *config.Config, tags []string, release *models.Release) (*models.Release, error) {
-	var err error
-	for i := 0; i < len(tags); i++ {
-		err = config.Repo.Tag().CreateOrLinkTag(tags[i], release)
-
-		if err != nil {
-			break
-		}
-	}
-
-	if err != nil {
-		return nil, err
-	}
-
-	return config.Repo.Release().ReadRelease(release.ClusterID, release.Name, release.Namespace)
-}

+ 3 - 1
api/server/handlers/release/update_tags.go

@@ -56,13 +56,15 @@ func (c *UpdateReleaseTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	release, err = LinkTagsToRelease(c.Config(), request.Tags, release)
+	_, err = c.Config().Repo.Tag().LinkTagsToRelease(request.Tags, release)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	release, err = c.Config().Repo.Release().ReadRelease(cluster.ID, name, namespace)
+
 	w.WriteHeader(http.StatusCreated)
 	c.WriteResult(w, r, release)
 }

+ 28 - 0
api/server/router/project.go

@@ -973,5 +973,33 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/tags -> project.NewCreateTagHandler
+	createTagEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/tags",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createTagHandler := project.NewCreateTagHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createTagEndpoint,
+		Handler:  createTagHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 6 - 0
api/types/tag.go

@@ -0,0 +1,6 @@
+package types
+
+type CreateTagRequest struct {
+	Name  string `json:"name" form:"required"`
+	Color string `json:"color" form:"required"`
+}

+ 134 - 0
dashboard/package-lock.json

@@ -47,6 +47,7 @@
         "random-words": "^1.1.1",
         "react": "^16.13.1",
         "react-ace": "^9.1.3",
+        "react-color": "^2.19.3",
         "react-dom": "^16.13.1",
         "react-error-boundary": "^3.1.3",
         "react-infinite-scroll-component": "^6.1.0",
@@ -83,6 +84,7 @@
         "@types/qs": "^6.9.5",
         "@types/random-words": "^1.1.0",
         "@types/react": "^16.14.14",
+        "@types/react-color": "^3.0.6",
         "@types/react-dom": "^16.9.8",
         "@types/react-modal": "^3.10.6",
         "@types/react-router": "^5.1.8",
@@ -1866,6 +1868,14 @@
       "integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==",
       "dev": true
     },
+    "node_modules/@icons/material": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
+      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==",
+      "peerDependencies": {
+        "react": "*"
+      }
+    },
     "node_modules/@ironplans/api": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@ironplans/api/-/api-0.4.1.tgz",
@@ -2800,6 +2810,16 @@
         "@types/react": "*"
       }
     },
+    "node_modules/@types/react-color": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.6.tgz",
+      "integrity": "sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*",
+        "@types/reactcss": "*"
+      }
+    },
     "node_modules/@types/react-dom": {
       "version": "16.9.14",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.14.tgz",
@@ -2860,6 +2880,15 @@
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz",
       "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA=="
     },
+    "node_modules/@types/reactcss": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz",
+      "integrity": "sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
     "node_modules/@types/scheduler": {
       "version": "0.16.2",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@@ -8283,6 +8312,11 @@
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
     },
+    "node_modules/lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
+    },
     "node_modules/lodash.debounce": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -8424,6 +8458,11 @@
         "react": ">= 0.14.0"
       }
     },
+    "node_modules/material-colors": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
+      "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg=="
+    },
     "node_modules/math-expression-evaluator": {
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.3.8.tgz",
@@ -9994,6 +10033,23 @@
         "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0"
       }
     },
+    "node_modules/react-color": {
+      "version": "2.19.3",
+      "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
+      "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
+      "dependencies": {
+        "@icons/material": "^0.2.4",
+        "lodash": "^4.17.15",
+        "lodash-es": "^4.17.15",
+        "material-colors": "^1.2.1",
+        "prop-types": "^15.5.10",
+        "reactcss": "^1.2.0",
+        "tinycolor2": "^1.4.1"
+      },
+      "peerDependencies": {
+        "react": "*"
+      }
+    },
     "node_modules/react-dom": {
       "version": "16.14.0",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
@@ -10152,6 +10208,14 @@
         "react-dom": ">=16.13"
       }
     },
+    "node_modules/reactcss": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
+      "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
+      "dependencies": {
+        "lodash": "^4.0.1"
+      }
+    },
     "node_modules/readable-stream": {
       "version": "2.3.7",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
@@ -11896,6 +11960,14 @@
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
       "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
     },
+    "node_modules/tinycolor2": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz",
+      "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/to-arraybuffer": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
@@ -15341,6 +15413,12 @@
       "integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==",
       "dev": true
     },
+    "@icons/material": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
+      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==",
+      "requires": {}
+    },
     "@ironplans/api": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@ironplans/api/-/api-0.4.1.tgz",
@@ -16075,6 +16153,16 @@
         "@types/react": "*"
       }
     },
+    "@types/react-color": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.6.tgz",
+      "integrity": "sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*",
+        "@types/reactcss": "*"
+      }
+    },
     "@types/react-dom": {
       "version": "16.9.14",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.14.tgz",
@@ -16130,6 +16218,15 @@
         "@types/react": "*"
       }
     },
+    "@types/reactcss": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz",
+      "integrity": "sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/scheduler": {
       "version": "0.16.2",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@@ -20557,6 +20654,11 @@
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
     },
+    "lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
+    },
     "lodash.debounce": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -20663,6 +20765,11 @@
       "integrity": "sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w==",
       "requires": {}
     },
+    "material-colors": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
+      "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg=="
+    },
     "math-expression-evaluator": {
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.3.8.tgz",
@@ -21952,6 +22059,20 @@
         "prop-types": "^15.7.2"
       }
     },
+    "react-color": {
+      "version": "2.19.3",
+      "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
+      "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
+      "requires": {
+        "@icons/material": "^0.2.4",
+        "lodash": "^4.17.15",
+        "lodash-es": "^4.17.15",
+        "material-colors": "^1.2.1",
+        "prop-types": "^15.5.10",
+        "reactcss": "^1.2.0",
+        "tinycolor2": "^1.4.1"
+      }
+    },
     "react-dom": {
       "version": "16.14.0",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz",
@@ -22069,6 +22190,14 @@
         "debounce": "^1.2.0"
       }
     },
+    "reactcss": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
+      "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
+      "requires": {
+        "lodash": "^4.0.1"
+      }
+    },
     "readable-stream": {
       "version": "2.3.7",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
@@ -23512,6 +23641,11 @@
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
       "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
     },
+    "tinycolor2": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz",
+      "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="
+    },
     "to-arraybuffer": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",

+ 2 - 0
dashboard/package.json

@@ -42,6 +42,7 @@
     "random-words": "^1.1.1",
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
+    "react-color": "^2.19.3",
     "react-dom": "^16.13.1",
     "react-error-boundary": "^3.1.3",
     "react-infinite-scroll-component": "^6.1.0",
@@ -84,6 +85,7 @@
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
     "@types/react": "^16.14.14",
+    "@types/react-color": "^3.0.6",
     "@types/react-dom": "^16.9.8",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",

+ 0 - 119
dashboard/src/components/Autocomplete.tsx

@@ -1,119 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { Autocomplete as MaterialAutocomplete } from "@material-ui/lab";
-import styled from "styled-components";
-
-type Props = {
-  options: any[];
-  defaultValue: any[];
-  onChange: (values: any[]) => void;
-};
-
-const Autocomplete = ({ options, defaultValue, onChange }: Props) => {
-  const [values, setValues] = useState(() => defaultValue || []);
-
-  useEffect(() => {
-    onChange(values);
-  }, [values]);
-
-  return (
-    <MaterialAutocomplete
-      multiple
-      filterSelectedOptions
-      options={options}
-      onChange={(_, value, type, details) => {
-        if (type === "create-option") {
-          value.splice(value.length - 1, 1);
-          setValues([...value, { name: details.option }]);
-          return;
-        }
-        setValues(value);
-      }}
-      value={values}
-      getOptionLabel={(option) => option.name}
-      renderTags={(values, getChipProps) => {
-        return values.map((val, index) => {
-          // @ts-ignore
-          const { onDelete, ...chipProps } = getChipProps({ index });
-
-          return (
-            <Tag {...chipProps} color={val.color}>
-              <TagText>{val.name}</TagText>
-              <i
-                aria-role={"button"}
-                className="material-icons"
-                onClick={onDelete}
-              >
-                delete
-              </i>
-            </Tag>
-          );
-        });
-      }}
-      renderInput={(params) => {
-        console.log(params);
-        return (
-          <>
-            <InputWrapper ref={params.InputProps.ref}>
-              <Input {...params.inputProps} />
-            </InputWrapper>
-            {params.InputProps.startAdornment}
-          </>
-        );
-      }}
-    ></MaterialAutocomplete>
-  );
-};
-
-export default Autocomplete;
-
-const Tag = styled.div<{ color: string }>`
-  color: ${(props) => props.color || "inherit"};
-  user-select: none;
-  border: 1px solid black;
-  border-radius: 15px;
-  padding: 5px 10px;
-  text-align: center;
-  display: flex;
-  align-items: center;
-  font-size: 14px;
-  background-color: ${(props) => props.color || "inherit"};
-  margin-left: 10px;
-  margin-top: 5px;
-  margin-bottom: 5px;
-
-  > .material-icons {
-    font-size: 20px;
-    margin-left: 5px;
-    filter: invert(1);
-    :hover {
-      cursor: pointer;
-    }
-  }
-`;
-
-const TagText = styled.span`
-  filter: invert(1);
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  margin-bottom: -1px;
-  align-items: center;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  background: #ffffff11;
-
-  ${Tag} {
-  }
-`;
-
-const Input = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  color: #ffffff;
-  padding: 5px 10px;
-  min-height: 35px;
-  max-height: 45px;
-`;

+ 3 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -16,10 +16,8 @@ import useAuth from "shared/auth/useAuth";
 import Loading from "components/Loading";
 import NotificationSettingsSection from "./NotificationSettingsSection";
 import { Link } from "react-router-dom";
-import Autocomplete from "components/Autocomplete";
-// import { Autocomplete, AutocompleteGetTagProps } from "@material-ui/lab";
-// import TextField from "@material-ui/core/TextField";
 import { isDeployedFromGithub } from "shared/release/utils";
+import TagSelector from "./TagSelector";
 
 type PropsType = {
   currentChart: ChartType;
@@ -231,7 +229,8 @@ const SettingsSection: React.FC<PropsType> = ({
     return (
       <>
         <Heading>Application tags</Heading>
-        <Autocomplete
+        <TagSelector
+          release={currentChart}
           defaultValue={
             currentChart.tags?.map((tagName: string) => ({
               name: tagName,

+ 272 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/TagSelector.tsx

@@ -0,0 +1,272 @@
+import React, { useContext, useEffect, useState } from "react";
+import { Autocomplete as MaterialAutocomplete } from "@material-ui/lab";
+import styled from "styled-components";
+import { Tooltip } from "@material-ui/core";
+import Modal from "main/home/modals/Modal";
+import { TwitterPicker } from "react-color";
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+
+type Props = {
+  options: any[];
+  defaultValue: any[];
+  onChange: (values: any[]) => void;
+  release: ChartType;
+};
+
+const TagSelector = ({ options, defaultValue, onChange, release }: Props) => {
+  const [values, setValues] = useState(() => defaultValue || []);
+  const [openModal, setOpenModal] = useState(false);
+
+  useEffect(() => {
+    onChange(values);
+  }, [values]);
+
+  const onDelete = (index: number) => {
+    setValues((prev) => {
+      const newValues = [...prev];
+      newValues.splice(index, 1);
+      return newValues;
+    });
+  };
+
+  return (
+    <>
+      {openModal ? (
+        <CreateTagModal
+          onSave={(newTag) => setValues((prev) => [...prev, newTag])}
+          onClose={() => setOpenModal(false)}
+          release={release}
+        />
+      ) : null}
+      <Flex>
+        <MaterialAutocomplete
+          fullWidth
+          filterSelectedOptions
+          options={options.filter(
+            (option) => !values.find((v) => v.name === option.name)
+          )}
+          onChange={(_, value) => {
+            setValues((prev) => [...prev, value]);
+          }}
+          getOptionLabel={(option) => option.name}
+          getOptionSelected={(option, value) => option.name === value}
+          renderInput={(params) => {
+            console.log(params);
+            return (
+              <>
+                <InputWrapper ref={params.InputProps.ref}>
+                  <Input {...params.inputProps} />
+                </InputWrapper>
+                {params.InputProps.startAdornment}
+              </>
+            );
+          }}
+        ></MaterialAutocomplete>
+        <Tooltip title="Create a new tag">
+          <AddButton
+            className="material-icons-outlined"
+            onClick={() => setOpenModal((prev) => !prev)}
+          >
+            add
+          </AddButton>
+        </Tooltip>
+      </Flex>
+      {values.map((val, index) => {
+        return (
+          <Tag color={val.color} key={index}>
+            <Tooltip title={val.name}>
+              <TagText>{val.name}</TagText>
+            </Tooltip>
+            <i className="material-icons" onClick={() => onDelete(index)}>
+              delete
+            </i>
+          </Tag>
+        );
+      })}
+    </>
+  );
+};
+
+const CreateTagModal = ({
+  onSave,
+  onClose,
+  release,
+}: {
+  onSave: (tag: any) => void;
+  onClose: () => void;
+  release: ChartType;
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [color, setColor] = useState("#ffffff");
+  const [name, setName] = useState("some-random-tag");
+
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const createTag = async () => {
+    setButtonStatus("loading");
+    try {
+      await api.createTag(
+        "<token>",
+        { name, color },
+        {
+          project_id: currentProject.id,
+        }
+      );
+    } catch (error) {
+      setCurrentError(error);
+      setButtonStatus("Couldn't create the tag");
+      return;
+    }
+
+    try {
+      await api.updateReleaseTags(
+        "<token>",
+        { tags: [...(release.tags || []), name] },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: release.namespace,
+          release_name: release.name,
+        }
+      );
+      setButtonStatus("successful");
+      onSave({ name, color });
+      onClose();
+    } catch (error) {
+      console.log(error);
+      setCurrentError(
+        "We couldn't link the tag to the release, please link it manually from the settings tab."
+      );
+      setButtonStatus("Couldn't link the tag to the release");
+      return;
+    }
+  };
+
+  return (
+    <Modal title="Create a new tag" onRequestClose={onClose} height="auto">
+      <InputRow
+        type="text"
+        label="Tag name"
+        value={name}
+        setValue={(val) => setName(val as string)}
+        isRequired
+        width="300px"
+      ></InputRow>
+      <Label>Tag color</Label>
+      <TwitterPicker
+        triangle="hide"
+        color={color}
+        onChange={(newColor) => setColor(newColor.hex)}
+      ></TwitterPicker>
+
+      <Label style={{ marginTop: "15px" }}>Result</Label>
+      <Tag color={color} style={{ maxWidth: "none", marginTop: "0px" }}>
+        <TagText>{name}</TagText>
+      </Tag>
+      <SaveButton
+        onClick={() => createTag()}
+        text={"Create Tag"}
+        disabled={!name.length || buttonStatus === "loading"}
+      ></SaveButton>
+    </Modal>
+  );
+};
+
+export default TagSelector;
+
+const Flex = styled.div`
+  display: flex;
+`;
+
+const AddButton = styled.div`
+  border-radius: 50%;
+  border: 1px solid #ffffff11;
+  padding: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 10px;
+  background: #ffffff11;
+  color: #ffffff88;
+  :hover {
+    background: #ffffff22;
+    color: #ffffff;
+    cursor: pointer;
+  }
+`;
+
+const Tag = styled.div<{ color: string }>`
+  display: inline-flex;
+  color: ${(props) => props.color || "inherit"};
+  user-select: none;
+  border: 1px solid black;
+  border-radius: 15px;
+  padding: 5px 10px;
+  text-align: center;
+  align-items: center;
+  font-size: 14px;
+  background-color: ${(props) => props.color || "inherit"};
+  margin-top: 15px;
+  margin-bottom: 5px;
+
+  max-width: 150px;
+  min-height: 30px;
+  min-width: 60px;
+
+  :not(:last-child) {
+    margin-right: 10px;
+  }
+
+  > .material-icons {
+    font-size: 20px;
+    margin-left: 5px;
+    filter: invert(1);
+    :hover {
+      cursor: pointer;
+    }
+  }
+`;
+
+const TagText = styled.span`
+  mix-blend-mode: difference;
+
+  overflow-x: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  margin-bottom: -1px;
+  align-items: center;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  background: #ffffff11;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  color: #ffffff;
+  padding: 5px 10px;
+  min-height: 35px;
+  max-height: 45px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;

+ 23 - 0
dashboard/src/shared/api.tsx

@@ -1758,6 +1758,27 @@ const getTagsByProjectId = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/tags`
 );
 
+const createTag = baseApi<
+  { name: string; color: string },
+  { project_id: number }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/tags`);
+
+const updateReleaseTags = baseApi<
+  {
+    tags: string[];
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, release_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/0/update_tags`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1923,4 +1944,6 @@ export default {
   reRunGHWorkflow,
   triggerPreviewEnvWorkflow,
   getTagsByProjectId,
+  createTag,
+  updateReleaseTags,
 };

+ 7 - 0
dashboard/src/shared/baseApi.ts

@@ -58,6 +58,13 @@ const buildAxiosConfig: BuildAxiosConfigFunction = (
     };
   }
 
+  if (method.toUpperCase() === "PATCH") {
+    return {
+      ...config,
+      data: params,
+    };
+  }
+
   return config;
 };
 

+ 1 - 1
internal/models/release.go

@@ -25,7 +25,7 @@ type Release struct {
 	EventContainer     uint
 	NotificationConfig uint
 	BuildConfig        uint
-	Tags               []Tag `json:"tags" gorm:"many2many:release_tags"`
+	Tags               []*Tag `json:"tags" gorm:"many2many:release_tags"`
 }
 
 func (r *Release) ToReleaseType() *types.PorterRelease {

+ 7 - 49
internal/repository/gorm/tag.go

@@ -1,24 +1,13 @@
 package gorm
 
 import (
-	"crypto/rand"
-	"encoding/hex"
 	"fmt"
-	"strings"
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 )
 
-func randomHex(n int) (string, error) {
-	bytes := make([]byte, n)
-	if _, err := rand.Read(bytes); err != nil {
-		return "", err
-	}
-	return hex.EncodeToString(bytes), nil
-}
-
 // AllowlistRepository uses gorm.DB for querying the database
 type TagRepository struct {
 	db *gorm.DB
@@ -43,32 +32,21 @@ func (repo *TagRepository) CreateTag(tag *models.Tag) (*models.Tag, error) {
 	return tag, nil
 }
 
-func (repo *TagRepository) CreateOrLinkTag(tagName string, release *models.Release) error {
-	project_id := release.ProjectID
-
-	existingTag, _ := repo.ReadTagByNameAndProjectId(tagName, project_id)
-
-	if existingTag != nil {
-		return repo.AddTagToRelease(release, existingTag)
-	}
-
-	randomColor, err := randomHex(3)
+func (repo *TagRepository) LinkTagsToRelease(tags []string, release *models.Release) ([]*models.Tag, error) {
+	populatedTags := make([]*models.Tag, 0)
+	err := repo.db.Model(&models.Tag{}).Where("name IN ?", tags).Where("project_id = ?", release.ProjectID).Find(&populatedTags).Error
 
 	if err != nil {
-		randomColor = "ffffff"
+		return nil, err
 	}
 
-	newTag := &models.Tag{
-		Name:      tagName,
-		ProjectID: project_id,
-		Color:     strings.Join([]string{"#", randomColor}, ""),
-	}
+	err = repo.db.Model(&release).Association("Tags").Append(populatedTags)
 
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	return repo.AddTagToRelease(release, newTag)
+	return populatedTags, nil
 }
 
 func (repo *TagRepository) UnlinkTagsFromRelease(tags []string, release *models.Release) error {
@@ -136,23 +114,3 @@ func (repo *TagRepository) DeleteTag(id uint) error {
 
 	return nil
 }
-
-func (repo *TagRepository) AddTagToRelease(release *models.Release, tag *models.Tag) error {
-	err := repo.db.Model(&release).Association("Tags").Append(tag)
-
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (repo *TagRepository) RemoveTagFromRelease(release *models.Release, tag *models.Tag) error {
-	err := repo.db.Model(&release).Association("Tags").Delete(tag)
-
-	if err != nil {
-		return err
-	}
-
-	return nil
-}

+ 1 - 3
internal/repository/tag.go

@@ -6,12 +6,10 @@ import "github.com/porter-dev/porter/internal/models"
 // GitRepo model
 type TagRepository interface {
 	CreateTag(tag *models.Tag) (*models.Tag, error)
-	CreateOrLinkTag(tagName string, release *models.Release) error
 	ReadTagByNameAndProjectId(tagName string, projectID uint) (*models.Tag, error)
 	ListTagsByProjectId(projectId uint) ([]*models.Tag, error)
 	UpdateTag(tag *models.Tag) (*models.Tag, error)
 	DeleteTag(id uint) error
-	AddTagToRelease(release *models.Release, tag *models.Tag) error
-	RemoveTagFromRelease(release *models.Release, tag *models.Tag) error
 	UnlinkTagsFromRelease(tags []string, release *models.Release) error
+	LinkTagsToRelease(tags []string, release *models.Release) ([]*models.Tag, error)
 }