Преглед изворни кода

[web] Colour picker for tags (#12255)

* Base for color picker

* React color picker and updated modals

* Add tag color picker to mobile dashboard

* Update existing tests and fix disable save button condition

* CSS adaptations for desktop modal streched into mobile display

* Update TagsController tests

* Add aria-hidden label on color pickers

* Fix linting

* Fix project list test

* Select random color when creating tag

* Cleanup leftovers in project list context

* Test cleanup

* Pre-select custom color and store local color while picking

* Add type to preset colors

* Add css fix to override disabled button opacity

* Skip redundant check

* Fix linting

* Add back btn-secondary on manage tag modal after rebase

GitOrigin-RevId: a4cf24e85cc0ca01466f4bf9c77482be8360e68e
Alexandre Bourdin пре 3 година
родитељ
комит
04c204f989
32 измењених фајлова са 1029 додато и 359 уклоњено
  1. 123 0
      package-lock.json
  2. 15 2
      services/web/app/src/Features/Tags/TagsController.js
  3. 22 2
      services/web/app/src/Features/Tags/TagsHandler.js
  4. 2 1
      services/web/app/src/Features/Tags/types.d.ts
  5. 11 0
      services/web/app/src/models/Tag.js
  6. 13 0
      services/web/app/src/router.js
  7. 2 2
      services/web/frontend/extracted-translations.json
  8. 133 0
      services/web/frontend/js/features/project-list/components/color-picker/color-picker.tsx
  9. 25 12
      services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx
  10. 88 85
      services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx
  11. 155 0
      services/web/frontend/js/features/project-list/components/modals/manage-tag-modal.tsx
  12. 0 132
      services/web/frontend/js/features/project-list/components/modals/rename-tag-modal.tsx
  13. 4 1
      services/web/frontend/js/features/project-list/components/project-list-root.tsx
  14. 8 10
      services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx
  15. 2 2
      services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx
  16. 8 0
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
  17. 7 7
      services/web/frontend/js/features/project-list/components/tags-list.tsx
  18. 59 0
      services/web/frontend/js/features/project-list/context/color-picker-context.tsx
  19. 22 13
      services/web/frontend/js/features/project-list/context/project-list-context.tsx
  20. 39 0
      services/web/frontend/js/features/project-list/hooks/use-select-color.ts
  21. 39 33
      services/web/frontend/js/features/project-list/hooks/use-tag.tsx
  22. 9 5
      services/web/frontend/js/features/project-list/util/api.ts
  23. 10 0
      services/web/frontend/js/features/project-list/util/tag.ts
  24. 17 0
      services/web/frontend/stories/project-list/color-picker.stories.tsx
  25. 92 0
      services/web/frontend/stylesheets/app/project-list-react.less
  26. 2 0
      services/web/locales/en.json
  27. 2 0
      services/web/package.json
  28. 4 4
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  29. 30 24
      services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx
  30. 6 1
      services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
  31. 74 22
      services/web/test/unit/src/Tags/TagsControllerTests.js
  32. 6 1
      services/web/test/unit/src/Tags/TagsHandlerTests.js

+ 123 - 0
package-lock.json

@@ -4947,6 +4947,14 @@
       "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
       "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/@istanbuljs/load-nyc-config": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -9195,6 +9203,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": "17.0.13",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.13.tgz",
@@ -9208,6 +9226,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/recurly__recurly-js": {
       "version": "4.22.0",
       "resolved": "https://registry.npmjs.org/@types/recurly__recurly-js/-/recurly__recurly-js-4.22.0.tgz",
@@ -23229,6 +23256,11 @@
         "remove-accents": "0.4.2"
       }
     },
+    "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-log2": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz",
@@ -27181,6 +27213,23 @@
         "react": "^16.8.0 || ^17.0.0 || ^18.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-dnd": {
       "version": "11.1.3",
       "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-11.1.3.tgz",
@@ -27374,6 +27423,14 @@
         "uuid": "dist/bin/uuid"
       }
     },
+    "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/read": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
@@ -30996,6 +31053,11 @@
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
       "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
     },
+    "node_modules/tinycolor2": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+    },
     "node_modules/tlds": {
       "version": "1.228.0",
       "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.228.0.tgz",
@@ -35178,6 +35240,7 @@
         "react": "^17.0.2",
         "react-bootstrap": "^0.33.1",
         "react-chartjs-2": "^5.0.1",
+        "react-color": "^2.19.3",
         "react-dnd": "^11.1.3",
         "react-dnd-html5-backend": "^11.1.3",
         "react-dom": "^17.0.2",
@@ -35224,6 +35287,7 @@
         "@types/mocha-each": "^2.0.0",
         "@types/react": "^17.0.40",
         "@types/react-bootstrap": "^0.32.29",
+        "@types/react-color": "^3.0.6",
         "@types/react-dom": "^17.0.13",
         "@types/recurly__recurly-js": "^4.22.0",
         "@types/sinon-chai": "^3.2.8",
@@ -41265,6 +41329,12 @@
       "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
       "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": {}
+    },
     "@istanbuljs/load-nyc-config": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -44757,6 +44827,7 @@
         "@types/mocha-each": "^2.0.0",
         "@types/react": "^17.0.40",
         "@types/react-bootstrap": "^0.32.29",
+        "@types/react-color": "*",
         "@types/react-dom": "^17.0.13",
         "@types/recurly__recurly-js": "^4.22.0",
         "@types/sinon-chai": "^3.2.8",
@@ -44918,6 +44989,7 @@
         "react": "^17.0.2",
         "react-bootstrap": "^0.33.1",
         "react-chartjs-2": "^5.0.1",
+        "react-color": "^2.19.3",
         "react-dnd": "^11.1.3",
         "react-dnd-html5-backend": "^11.1.3",
         "react-dom": "^17.0.2",
@@ -47832,6 +47904,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": "17.0.13",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.13.tgz",
@@ -47840,6 +47922,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/recurly__recurly-js": {
       "version": "4.22.0",
       "resolved": "https://registry.npmjs.org/@types/recurly__recurly-js/-/recurly__recurly-js-4.22.0.tgz",
@@ -58929,6 +59020,11 @@
         "remove-accents": "0.4.2"
       }
     },
+    "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-log2": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz",
@@ -62206,6 +62302,20 @@
       "integrity": "sha512-u38C9OxynlNCBp+79grgXRs7DSJ9w8FuQ5/HO5FbYBbri8HSZW+9SWgjVshLkbXBfXnMGWakbHEtvN0nL2UG7Q==",
       "requires": {}
     },
+    "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-dnd": {
       "version": "11.1.3",
       "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-11.1.3.tgz",
@@ -62344,6 +62454,14 @@
         }
       }
     },
+    "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"
+      }
+    },
     "read": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
@@ -65263,6 +65381,11 @@
       "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
       "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
     },
+    "tinycolor2": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+    },
     "tlds": {
       "version": "1.228.0",
       "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.228.0.tgz",

+ 15 - 2
services/web/app/src/Features/Tags/TagsController.js

@@ -23,8 +23,8 @@ async function getAllTags(req, res) {
 
 async function createTag(req, res) {
   const userId = SessionManager.getLoggedInUserId(req.session)
-  const { name } = req.body
-  const tag = await TagsHandler.promises.createTag(userId, name)
+  const { name, color } = req.body
+  const tag = await TagsHandler.promises.createTag(userId, name, color)
   res.json(tag)
 }
 
@@ -76,6 +76,18 @@ async function renameTag(req, res) {
   res.status(204).end()
 }
 
+async function editTag(req, res) {
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const { tagId } = req.params
+  const name = req.body?.name
+  const color = req.body?.color
+  if (!name) {
+    return res.status(400).end()
+  }
+  await TagsHandler.promises.editTag(userId, tagId, name, color)
+  res.status(204).end()
+}
+
 module.exports = {
   apiGetAllTags: expressify(apiGetAllTags),
   getAllTags: expressify(getAllTags),
@@ -86,4 +98,5 @@ module.exports = {
   removeProjectsFromTag: expressify(removeProjectsFromTag),
   deleteTag: expressify(deleteTag),
   renameTag: expressify(renameTag),
+  editTag: expressify(editTag),
 }

+ 22 - 2
services/web/app/src/Features/Tags/TagsHandler.js

@@ -7,14 +7,14 @@ function getAllTags(userId, callback) {
   Tag.find({ user_id: userId }, callback)
 }
 
-function createTag(userId, name, callback) {
+function createTag(userId, name, color, callback) {
   if (!callback) {
     callback = function () {}
   }
   if (name.length > MAX_TAG_LENGTH) {
     return callback(new Error('Exceeded max tag length'))
   }
-  Tag.create({ user_id: userId, name }, function (err, tag) {
+  Tag.create({ user_id: userId, name, color }, function (err, tag) {
     // on duplicate key error return existing tag
     if (err && err.code === 11000) {
       return Tag.findOne({ user_id: userId, name }, callback)
@@ -44,6 +44,25 @@ function renameTag(userId, tagId, name, callback) {
   )
 }
 
+function editTag(userId, tagId, name, color, callback) {
+  if (name.length > MAX_TAG_LENGTH) {
+    return callback(new Error('Exceeded max tag length'))
+  }
+  Tag.updateOne(
+    {
+      _id: tagId,
+      user_id: userId,
+    },
+    {
+      $set: {
+        name,
+        color,
+      },
+    },
+    callback
+  )
+}
+
 function deleteTag(userId, tagId, callback) {
   if (!callback) {
     callback = function () {}
@@ -137,6 +156,7 @@ const TagsHandler = {
   getAllTags,
   createTag,
   renameTag,
+  editTag,
   deleteTag,
   updateTagUserIds,
   addProjectToTag,

+ 2 - 1
services/web/app/src/Features/Tags/types.d.ts

@@ -1,6 +1,7 @@
 export type Tag = {
   _id: string
   user_id: string
-  name: string | null
+  name: string
+  color?: string
   project_ids?: string[]
 }

+ 11 - 0
services/web/app/src/models/Tag.js

@@ -1,6 +1,8 @@
 const mongoose = require('../infrastructure/Mongoose')
 const { Schema } = mongoose
 
+const COLOR_REGEX = /^#[a-fA-F0-9]{6}$/
+
 // Note that for legacy reasons, user_id and project_ids are plain strings,
 // not ObjectIds.
 
@@ -8,6 +10,15 @@ const TagSchema = new Schema(
   {
     user_id: { type: String, required: true },
     name: { type: String, required: true },
+    color: {
+      type: String,
+      validate: {
+        validator: function (v) {
+          return !v || COLOR_REGEX.test(v)
+        },
+        message: 'Provided color code is invalid.',
+      },
+    },
     project_ids: [String],
   },
   { minimize: false }

+ 13 - 0
services/web/app/src/router.js

@@ -878,6 +878,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
     validate({
       body: Joi.object({
         name: Joi.string().required(),
+        color: Joi.string(),
       }),
     }),
     TagsController.createTag
@@ -893,6 +894,18 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
     }),
     TagsController.renameTag
   )
+  webRouter.post(
+    '/tag/:tagId/edit',
+    AuthenticationController.requireLogin(),
+    RateLimiterMiddleware.rateLimit(rateLimiters.renameTag),
+    validate({
+      body: Joi.object({
+        name: Joi.string().required(),
+        color: Joi.string(),
+      }),
+    }),
+    TagsController.editTag
+  )
   webRouter.delete(
     '/tag/:tagId',
     AuthenticationController.requireLogin(),

+ 2 - 2
services/web/frontend/extracted-translations.json

@@ -265,6 +265,7 @@
   "first_name": "",
   "first_x_days_free_after_that_y_per_month": "",
   "fold_line": "",
+  "folder_color": "",
   "following_paths_conflict": "",
   "font_family": "",
   "font_size": "",
@@ -690,9 +691,7 @@
   "remove_tag": "",
   "removing": "",
   "rename": "",
-  "rename_folder": "",
   "rename_project": "",
-  "renaming": "",
   "repository_name": "",
   "republish": "",
   "resend": "",
@@ -705,6 +704,7 @@
   "revoke_invite": "",
   "rich_text_is_only_available_for_tex_files": "",
   "role": "",
+  "save": "",
   "save_or_cancel-cancel": "",
   "save_or_cancel-or": "",
   "save_or_cancel-save": "",

+ 133 - 0
services/web/frontend/js/features/project-list/components/color-picker/color-picker.tsx

@@ -0,0 +1,133 @@
+import Icon from '../../../../shared/components/icon'
+import useSelectColor from '../../hooks/use-select-color'
+import { SketchPicker } from 'react-color'
+import { useEffect, useState } from 'react'
+
+const PRESET_COLORS: ReadonlyArray<string> = [
+  '#A7B1C2',
+  '#F04343',
+  '#DD8A3E',
+  '#E4CA3E',
+  '#33CF67',
+  '#43A7F0',
+  '#434AF0',
+  '#B943F0',
+  '#FF4BCD',
+]
+
+type ColorPickerItemProps = {
+  color: string
+}
+
+function ColorPickerItem({ color }: ColorPickerItemProps) {
+  const { selectColor, selectedColor, pickingCustomColor } = useSelectColor()
+
+  return (
+    /* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
+      jsx-a11y/no-static-element-interactions, jsx-a11y/interactive-supports-focus */
+    <div
+      className="color-picker-item"
+      role="button"
+      style={{ backgroundColor: color }}
+      onClick={() => selectColor(color)}
+    >
+      {!pickingCustomColor && color === selectedColor && (
+        <Icon type="check" className="color-picker-item-icon" />
+      )}
+    </div>
+  )
+}
+
+function MoreButton() {
+  const {
+    selectedColor,
+    selectColor,
+    showCustomPicker,
+    openCustomPicker,
+    closeCustomPicker,
+    setPickingCustomColor,
+  } = useSelectColor()
+  const [localColor, setLocalColor] = useState<string>()
+
+  useEffect(() => {
+    setLocalColor(selectedColor)
+  }, [selectedColor])
+
+  const isCustomColorSelected =
+    localColor && !PRESET_COLORS.includes(localColor)
+
+  return (
+    <div className="color-picker-more-wrapper" data-content="My Content">
+      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
+      jsx-a11y/no-static-element-interactions, jsx-a11y/interactive-supports-focus */}
+      <div
+        className="color-picker-item more-button"
+        role="button"
+        onClick={showCustomPicker ? closeCustomPicker : openCustomPicker}
+        style={{
+          backgroundColor: isCustomColorSelected
+            ? localColor || selectedColor
+            : 'white',
+        }}
+      >
+        {isCustomColorSelected ? (
+          <Icon type="check" className="color-picker-item-icon" />
+        ) : showCustomPicker ? (
+          <Icon type="chevron-down" className="color-picker-more-open" />
+        ) : (
+          <Icon type="plus" className="color-picker-more" />
+        )}
+      </div>
+      {showCustomPicker && (
+        <>
+          {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
+      jsx-a11y/no-static-element-interactions, jsx-a11y/interactive-supports-focus */}
+          <div
+            className="popover-backdrop"
+            role="button"
+            onClick={() => closeCustomPicker()}
+          />
+          <SketchPicker
+            disableAlpha
+            presetColors={[]}
+            onChange={color => {
+              setPickingCustomColor(true)
+              setLocalColor(color.hex)
+            }}
+            onChangeComplete={color => {
+              selectColor(color.hex)
+              setPickingCustomColor(false)
+            }}
+            color={localColor}
+            className="custom-picker"
+          />
+        </>
+      )}
+    </div>
+  )
+}
+
+export function ColorPicker({
+  disableCustomColor,
+}: {
+  disableCustomColor?: boolean
+}) {
+  const { selectColor, selectedColor } = useSelectColor()
+
+  useEffect(() => {
+    if (!selectedColor) {
+      selectColor(
+        PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]
+      )
+    }
+  }, [selectColor, selectedColor])
+
+  return (
+    <>
+      {PRESET_COLORS.map(hexColor => (
+        <ColorPickerItem color={hexColor} key={hexColor} />
+      ))}
+      {!disableCustomColor && <MoreButton />}
+    </>
+  )
+}

+ 25 - 12
services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx

@@ -1,19 +1,22 @@
 import { useCallback, useEffect, useState } from 'react'
-import { Button, Form, Modal } from 'react-bootstrap'
+import { Button, ControlLabel, Form, FormGroup, Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Tag } from '../../../../../../app/src/Features/Tags/types'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
 import useAsync from '../../../../shared/hooks/use-async'
 import { useProjectListContext } from '../../context/project-list-context'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
+import useSelectColor from '../../hooks/use-select-color'
 import { createTag } from '../../util/api'
 import { MAX_TAG_LENGTH } from '../../util/tag'
+import { ColorPicker } from '../color-picker/color-picker'
 
 type CreateTagModalProps = {
   id: string
   show: boolean
   onCreate: (tag: Tag) => void
   onClose: () => void
+  disableCustomColor?: boolean
 }
 
 export default function CreateTagModal({
@@ -21,8 +24,10 @@ export default function CreateTagModal({
   show,
   onCreate,
   onClose,
+  disableCustomColor,
 }: CreateTagModalProps) {
   const { tags } = useProjectListContext()
+  const { selectedColor } = useSelectColor()
   const { t } = useTranslation()
   const { isError, runAsync, status } = useAsync<Tag>()
   const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
@@ -32,11 +37,11 @@ export default function CreateTagModal({
 
   const runCreateTag = useCallback(() => {
     if (tagName) {
-      runAsync(createTag(tagName))
+      runAsync(createTag(tagName, selectedColor))
         .then(tag => onCreate(tag))
         .catch(console.error)
     }
-  }, [runAsync, tagName, onCreate])
+  }, [runAsync, tagName, selectedColor, onCreate])
 
   const handleSubmit = useCallback(
     e => {
@@ -70,15 +75,23 @@ export default function CreateTagModal({
 
       <Modal.Body>
         <Form name="createTagForm" onSubmit={handleSubmit}>
-          <input
-            ref={autoFocusedRef}
-            className="form-control"
-            type="text"
-            placeholder="New Tag Name"
-            name="new-tag-form-name"
-            required
-            onChange={e => setTagName(e.target.value)}
-          />
+          <FormGroup>
+            <input
+              ref={autoFocusedRef}
+              className="form-control"
+              type="text"
+              placeholder="New Tag Name"
+              name="new-tag-form-name"
+              required
+              onChange={e => setTagName(e.target.value)}
+            />
+          </FormGroup>
+          <FormGroup aria-hidden="true">
+            <ControlLabel>{t('folder_color')}</ControlLabel>:{' '}
+            <div>
+              <ColorPicker disableCustomColor={disableCustomColor} />
+            </div>
+          </FormGroup>
         </Form>
       </Modal.Body>
 

+ 88 - 85
services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx

@@ -1,73 +1,76 @@
-import { useCallback, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
+import { Button, ControlLabel, Form, FormGroup, Modal } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
-import { Button, Form, Modal } from 'react-bootstrap'
+import { Tag } from '../../../../../../app/src/Features/Tags/types'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
 import useAsync from '../../../../shared/hooks/use-async'
+import { useProjectListContext } from '../../context/project-list-context'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
-import { deleteTag, renameTag } from '../../util/api'
-import { Tag } from '../../../../../../app/src/Features/Tags/types'
+import useSelectColor from '../../hooks/use-select-color'
+import { editTag } from '../../util/api'
+import { getTagColor, MAX_TAG_LENGTH } from '../../util/tag'
+import { ColorPicker } from '../color-picker/color-picker'
 
 type EditTagModalProps = {
   id: string
   tag?: Tag
-  onRename: (tagId: string, newTagName: string) => void
-  onDelete: (tagId: string) => void
+  onEdit: (tagId: string, newTagName: string, newTagColor?: string) => void
   onClose: () => void
 }
 
-export default function EditTagModal({
-  id,
-  tag,
-  onRename,
-  onDelete,
-  onClose,
-}: EditTagModalProps) {
+export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
+  const { tags } = useProjectListContext()
   const { t } = useTranslation()
+  const { isLoading, isError, runAsync, status } = useAsync()
   const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
-  const {
-    isLoading: isDeleteLoading,
-    isError: isDeleteError,
-    runAsync: runDeleteAsync,
-  } = useAsync()
-  const {
-    isLoading: isRenameLoading,
-    isError: isRenameError,
-    runAsync: runRenameAsync,
-  } = useAsync()
-  const [newTagName, setNewTagName] = useState<string>()
 
-  const runDeleteTag = useCallback(
-    (tagId: string) => {
-      runDeleteAsync(deleteTag(tagId))
-        .then(() => {
-          onDelete(tagId)
-        })
-        .catch(console.error)
-    },
-    [runDeleteAsync, onDelete]
-  )
+  const [newTagName, setNewTagName] = useState<string | undefined>()
+  const [validationError, setValidationError] = useState<string>()
 
-  const runRenameTag = useCallback(
+  const { selectedColor } = useSelectColor(getTagColor(tag))
+
+  useEffect(() => {
+    setNewTagName(tag?.name)
+  }, [tag])
+
+  const runEditTag = useCallback(
     (tagId: string) => {
       if (newTagName) {
-        runRenameAsync(renameTag(tagId, newTagName))
-          .then(() => onRename(tagId, newTagName))
+        const color = selectedColor
+        runAsync(editTag(tagId, newTagName, color))
+          .then(() => onEdit(tagId, newTagName, color))
           .catch(console.error)
       }
     },
-    [runRenameAsync, newTagName, onRename]
+    [runAsync, newTagName, selectedColor, onEdit]
   )
 
   const handleSubmit = useCallback(
     e => {
       e.preventDefault()
       if (tag) {
-        runRenameTag(tag._id)
+        runEditTag(tag._id)
       }
     },
-    [tag, runRenameTag]
+    [tag, runEditTag]
   )
 
+  useEffect(() => {
+    if (newTagName && newTagName.length > MAX_TAG_LENGTH) {
+      setValidationError(
+        t('tag_name_cannot_exceed_characters', { maxLength: MAX_TAG_LENGTH })
+      )
+    } else if (
+      newTagName &&
+      newTagName !== tag?.name &&
+      tags.find(tag => tag.name === newTagName)
+    ) {
+      setValidationError(t('tag_name_is_already_used', { tagName: newTagName }))
+    } else if (validationError) {
+      setValidationError(undefined)
+    }
+  }, [newTagName, tags, tag?.name, t, validationError])
+
   if (!tag) {
     return null
   }
@@ -79,62 +82,62 @@ export default function EditTagModal({
       </Modal.Header>
 
       <Modal.Body>
-        <Form name="editTagRenameForm" onSubmit={handleSubmit}>
-          <input
-            ref={autoFocusedRef}
-            className="form-control"
-            type="text"
-            placeholder="Tag Name"
-            name="new-tag-name"
-            value={newTagName === undefined ? tag.name ?? '' : newTagName}
-            required
-            onChange={e => setNewTagName(e.target.value)}
-          />
+        <Form name="renameTagForm" onSubmit={handleSubmit}>
+          <FormGroup>
+            <input
+              ref={autoFocusedRef}
+              className="form-control"
+              type="text"
+              placeholder="Tag Name"
+              name="new-tag-name"
+              value={newTagName === undefined ? tag.name ?? '' : newTagName}
+              required
+              onChange={e => setNewTagName(e.target.value)}
+            />
+          </FormGroup>
+          <FormGroup aria-hidden="true">
+            <ControlLabel>{t('folder_color')}</ControlLabel>:{' '}
+            <div>
+              <ColorPicker />
+            </div>
+          </FormGroup>
         </Form>
       </Modal.Body>
 
       <Modal.Footer>
-        <div className="clearfix">
+        {validationError && (
           <div className="modal-footer-left">
-            <Button
-              onClick={() => runDeleteTag(tag._id)}
-              bsStyle="danger"
-              disabled={isDeleteLoading || isRenameLoading}
-            >
-              {isDeleteLoading ? (
-                <>{t('deleting')} &hellip;</>
-              ) : (
-                t('delete_folder')
-              )}
-            </Button>
+            <span className="text-danger error">{validationError}</span>
           </div>
-          <Button
-            onClick={onClose}
-            disabled={isDeleteLoading || isRenameLoading}
-            bsStyle={null}
-            className="btn-secondary"
-          >
-            {t('save_or_cancel-cancel')}
-          </Button>
-          <Button
-            onClick={() => runRenameTag(tag._id)}
-            bsStyle="primary"
-            disabled={isRenameLoading || isDeleteLoading || !newTagName?.length}
-          >
-            {isRenameLoading ? (
-              <>{t('saving')} &hellip;</>
-            ) : (
-              t('save_or_cancel-save')
-            )}
-          </Button>
-        </div>
-        {(isDeleteError || isRenameError) && (
-          <div className="modal-footer-left mt-2">
+        )}
+        {isError && (
+          <div className="modal-footer-left">
             <span className="text-danger error">
               {t('generic_something_went_wrong')}
             </span>
           </div>
         )}
+        <Button
+          bsStyle={null}
+          className="btn-secondary"
+          onClick={onClose}
+          disabled={isLoading}
+        >
+          {t('cancel')}
+        </Button>
+        <Button
+          onClick={() => runEditTag(tag._id)}
+          bsStyle="primary"
+          disabled={
+            isLoading ||
+            status === 'pending' ||
+            !newTagName?.length ||
+            (newTagName === tag?.name && selectedColor === getTagColor(tag)) ||
+            !!validationError
+          }
+        >
+          {isLoading ? <>{t('saving')} &hellip;</> : t('save')}
+        </Button>
       </Modal.Footer>
     </AccessibleModal>
   )

+ 155 - 0
services/web/frontend/js/features/project-list/components/modals/manage-tag-modal.tsx

@@ -0,0 +1,155 @@
+import { useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Button, ControlLabel, Form, FormGroup, Modal } from 'react-bootstrap'
+import AccessibleModal from '../../../../shared/components/accessible-modal'
+import useAsync from '../../../../shared/hooks/use-async'
+import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
+import useSelectColor from '../../hooks/use-select-color'
+import { deleteTag, editTag } from '../../util/api'
+import { Tag } from '../../../../../../app/src/Features/Tags/types'
+import { getTagColor } from '../../util/tag'
+import { ColorPicker } from '../color-picker/color-picker'
+
+type ManageTagModalProps = {
+  id: string
+  tag?: Tag
+  onEdit: (tagId: string, newTagName: string, newTagColor?: string) => void
+  onDelete: (tagId: string) => void
+  onClose: () => void
+}
+
+export function ManageTagModal({
+  id,
+  tag,
+  onEdit,
+  onDelete,
+  onClose,
+}: ManageTagModalProps) {
+  const { t } = useTranslation()
+  const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
+  const {
+    isLoading: isDeleteLoading,
+    isError: isDeleteError,
+    runAsync: runDeleteAsync,
+  } = useAsync()
+  const {
+    isLoading: isUpdateLoading,
+    isError: isRenameError,
+    runAsync: runEditAsync,
+  } = useAsync()
+  const [newTagName, setNewTagName] = useState<string | undefined>(tag?.name)
+  const { selectedColor } = useSelectColor(tag?.color)
+
+  const runDeleteTag = useCallback(
+    (tagId: string) => {
+      runDeleteAsync(deleteTag(tagId))
+        .then(() => {
+          onDelete(tagId)
+        })
+        .catch(console.error)
+    },
+    [runDeleteAsync, onDelete]
+  )
+
+  const runUpdateTag = useCallback(
+    (tagId: string) => {
+      if (newTagName) {
+        runEditAsync(editTag(tagId, newTagName, selectedColor))
+          .then(() => onEdit(tagId, newTagName, selectedColor))
+          .catch(console.error)
+      }
+    },
+    [runEditAsync, newTagName, selectedColor, onEdit]
+  )
+
+  const handleSubmit = useCallback(
+    e => {
+      e.preventDefault()
+      if (tag) {
+        runUpdateTag(tag._id)
+      }
+    },
+    [tag, runUpdateTag]
+  )
+
+  if (!tag) {
+    return null
+  }
+
+  return (
+    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
+      <Modal.Header closeButton>
+        <Modal.Title>{t('edit_folder')}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body>
+        <Form name="editTagRenameForm" onSubmit={handleSubmit}>
+          <FormGroup>
+            <input
+              ref={autoFocusedRef}
+              className="form-control"
+              type="text"
+              placeholder="Tag Name"
+              name="new-tag-name"
+              value={newTagName === undefined ? tag.name ?? '' : newTagName}
+              required
+              onChange={e => setNewTagName(e.target.value)}
+            />
+          </FormGroup>
+          <FormGroup aria-hidden="true">
+            <ControlLabel>{t('folder_color')}</ControlLabel>:<br />
+            <ColorPicker disableCustomColor />
+          </FormGroup>
+        </Form>
+      </Modal.Body>
+
+      <Modal.Footer>
+        <div className="clearfix">
+          <div className="modal-footer-left">
+            <Button
+              onClick={() => runDeleteTag(tag._id)}
+              bsStyle="danger"
+              disabled={isDeleteLoading || isUpdateLoading}
+            >
+              {isDeleteLoading ? (
+                <>{t('deleting')} &hellip;</>
+              ) : (
+                t('delete_folder')
+              )}
+            </Button>
+          </div>
+          <Button
+            onClick={onClose}
+            disabled={isDeleteLoading || isUpdateLoading}
+          >
+            {t('save_or_cancel-cancel')}
+          </Button>
+          <Button
+            onClick={() => runUpdateTag(tag._id)}
+            bsStyle={null}
+            className="btn-secondary"
+            disabled={Boolean(
+              isUpdateLoading ||
+                isDeleteLoading ||
+                !newTagName?.length ||
+                (newTagName === tag?.name && selectedColor === getTagColor(tag))
+            )}
+          >
+            {isUpdateLoading ? (
+              <>{t('saving')} &hellip;</>
+            ) : (
+              t('save_or_cancel-save')
+            )}
+          </Button>
+        </div>
+        {(isDeleteError || isRenameError) && (
+          <div className="modal-footer-left mt-2">
+            <span className="text-danger error">
+              {t('generic_something_went_wrong')}
+            </span>
+          </div>
+        )}
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}

+ 0 - 132
services/web/frontend/js/features/project-list/components/modals/rename-tag-modal.tsx

@@ -1,132 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Button, Form, Modal } from 'react-bootstrap'
-import { useTranslation } from 'react-i18next'
-import { Tag } from '../../../../../../app/src/Features/Tags/types'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
-import useAsync from '../../../../shared/hooks/use-async'
-import { useProjectListContext } from '../../context/project-list-context'
-import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
-import { renameTag } from '../../util/api'
-import { MAX_TAG_LENGTH } from '../../util/tag'
-
-type RenameTagModalProps = {
-  id: string
-  tag?: Tag
-  onRename: (tagId: string, newTagName: string) => void
-  onClose: () => void
-}
-
-export default function RenameTagModal({
-  id,
-  tag,
-  onRename,
-  onClose,
-}: RenameTagModalProps) {
-  const { tags } = useProjectListContext()
-  const { t } = useTranslation()
-  const { isLoading, isError, runAsync, status } = useAsync()
-  const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
-
-  const [newTagName, setNewTagName] = useState<string>()
-  const [validationError, setValidationError] = useState<string>()
-
-  const runRenameTag = useCallback(
-    (tagId: string) => {
-      if (newTagName) {
-        runAsync(renameTag(tagId, newTagName))
-          .then(() => onRename(tagId, newTagName))
-          .catch(console.error)
-      }
-    },
-    [runAsync, newTagName, onRename]
-  )
-
-  const handleSubmit = useCallback(
-    e => {
-      e.preventDefault()
-      if (tag) {
-        runRenameTag(tag._id)
-      }
-    },
-    [tag, runRenameTag]
-  )
-
-  useEffect(() => {
-    if (newTagName && newTagName.length > MAX_TAG_LENGTH) {
-      setValidationError(
-        t('tag_name_cannot_exceed_characters', { maxLength: MAX_TAG_LENGTH })
-      )
-    } else if (
-      newTagName &&
-      newTagName !== tag?.name &&
-      tags.find(tag => tag.name === newTagName)
-    ) {
-      setValidationError(t('tag_name_is_already_used', { tagName: newTagName }))
-    } else if (validationError) {
-      setValidationError(undefined)
-    }
-  }, [newTagName, tags, tag?.name, t, validationError])
-
-  if (!tag) {
-    return null
-  }
-
-  return (
-    <AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
-      <Modal.Header closeButton>
-        <Modal.Title>{t('rename_folder')}</Modal.Title>
-      </Modal.Header>
-
-      <Modal.Body>
-        <Form name="renameTagForm" onSubmit={handleSubmit}>
-          <input
-            ref={autoFocusedRef}
-            className="form-control"
-            type="text"
-            placeholder="Tag Name"
-            name="new-tag-name"
-            value={newTagName === undefined ? tag.name ?? '' : newTagName}
-            required
-            onChange={e => setNewTagName(e.target.value)}
-          />
-        </Form>
-      </Modal.Body>
-
-      <Modal.Footer>
-        {validationError && (
-          <div className="modal-footer-left">
-            <span className="text-danger error">{validationError}</span>
-          </div>
-        )}
-        {isError && (
-          <div className="modal-footer-left">
-            <span className="text-danger error">
-              {t('generic_something_went_wrong')}
-            </span>
-          </div>
-        )}
-        <Button
-          bsStyle={null}
-          className="btn-secondary"
-          onClick={onClose}
-          disabled={isLoading}
-        >
-          {t('cancel')}
-        </Button>
-        <Button
-          onClick={() => runRenameTag(tag._id)}
-          bsStyle="primary"
-          disabled={
-            isLoading ||
-            status === 'pending' ||
-            newTagName === tag?.name ||
-            !newTagName?.length ||
-            !!validationError
-          }
-        >
-          {isLoading ? <>{t('renaming')} &hellip;</> : t('rename')}
-        </Button>
-      </Modal.Footer>
-    </AccessibleModal>
-  )
-}

+ 4 - 1
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -2,6 +2,7 @@ import {
   ProjectListProvider,
   useProjectListContext,
 } from '../context/project-list-context'
+import { ColorPickerProvider } from '../context/color-picker-context'
 import * as eventTracking from '../../../infrastructure/event-tracking'
 import { Col, Row } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
@@ -27,7 +28,9 @@ function ProjectListRoot() {
 
   return isReady ? (
     <ProjectListProvider>
-      <ProjectListPageContent />
+      <ColorPickerProvider>
+        <ProjectListPageContent />
+      </ColorPickerProvider>
     </ProjectListProvider>
   ) : null
 }

+ 8 - 10
services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx

@@ -1,13 +1,13 @@
 import { sortBy } from 'lodash'
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
-import ColorManager from '../../../../ide/colors/ColorManager'
 import Icon from '../../../../shared/components/icon'
 import {
   UNCATEGORIZED_KEY,
   useProjectListContext,
 } from '../../context/project-list-context'
 import useTag from '../../hooks/use-tag'
+import { getTagColor } from '../../util/tag'
 
 export default function TagsList() {
   const { t } = useTranslation()
@@ -21,10 +21,10 @@ export default function TagsList() {
   const {
     handleSelectTag,
     openCreateTagModal,
-    handleRenameTag,
+    handleEditTag,
     handleDeleteTag,
     CreateTagModal,
-    RenameTagModal,
+    EditTagModal,
     DeleteTagModal,
   } = useTag()
 
@@ -58,9 +58,7 @@ export default function TagsList() {
             >
               <span
                 style={{
-                  color: `hsl(${ColorManager.getHueForTagId(
-                    tag._id
-                  )}, 70%, 45%)`,
+                  color: getTagColor(tag),
                 }}
               >
                 <Icon
@@ -87,10 +85,10 @@ export default function TagsList() {
               <ul className="dropdown-menu dropdown-menu-right" role="menu">
                 <li>
                   <Button
-                    onClick={e => handleRenameTag(e, tag._id)}
+                    onClick={e => handleEditTag(e, tag._id)}
                     className="tag-action"
                   >
-                    {t('rename')}
+                    {t('edit')}
                   </Button>
                 </li>
                 <li>
@@ -121,8 +119,8 @@ export default function TagsList() {
         </Button>
       </li>
       <CreateTagModal id="create-tag-modal" />
-      <RenameTagModal id="delete-tag-modal" />
-      <DeleteTagModal id="rename-tag-modal" />
+      <EditTagModal id="edit-tag-modal" />
+      <DeleteTagModal id="delete-tag-modal" />
     </>
   )
 }

+ 2 - 2
services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx

@@ -1,11 +1,11 @@
 import { useCallback, useState, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import { Tag } from '../../../../../../../app/src/Features/Tags/types'
-import ColorManager from '../../../../../ide/colors/ColorManager'
 import Icon from '../../../../../shared/components/icon'
 import { useProjectListContext } from '../../../context/project-list-context'
 import { removeProjectFromTag } from '../../../util/api'
 import classnames from 'classnames'
+import { getTagColor } from '../../../util/tag'
 
 type InlineTagsProps = {
   projectId: string
@@ -71,7 +71,7 @@ function InlineTag({ tag, projectId }: InlineTagProps) {
       >
         <span
           style={{
-            color: `hsl(${ColorManager.getHueForTagId(tag._id)}, 70%, 45%)`,
+            color: getTagColor(tag),
           }}
         >
           <Icon type="circle" aria-hidden="true" />

+ 8 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx

@@ -7,6 +7,7 @@ import Icon from '../../../../../../shared/components/icon'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import useTag from '../../../../hooks/use-tag'
 import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api'
+import { getTagColor } from '../../../../util/tag'
 
 function TagsDropdown() {
   const {
@@ -117,6 +118,13 @@ function TagsDropdown() {
                         ? 'minus-square-o'
                         : 'square-o'
                     }
+                    className="tag-checkbox"
+                  />{' '}
+                  <span
+                    className="tag-dot"
+                    style={{
+                      backgroundColor: getTagColor(tag),
+                    }}
                   />{' '}
                   {tag.name}
                 </Button>

+ 7 - 7
services/web/frontend/js/features/project-list/components/tags-list.tsx

@@ -1,12 +1,12 @@
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
+import { getTagColor } from '../util/tag'
 import MenuItemButton from './dropdown/menu-item-button'
 import Icon from '../../../shared/components/icon'
 import {
   UNCATEGORIZED_KEY,
   useProjectListContext,
 } from '../context/project-list-context'
-import ColorManager from '../../../ide/colors/ColorManager'
 import useTag from '../hooks/use-tag'
 import { sortBy } from 'lodash'
 import { Tag } from '../../../../../app/src/Features/Tags/types'
@@ -24,9 +24,9 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
   const {
     handleSelectTag,
     openCreateTagModal,
-    handleEditTag,
+    handleManageTag,
     CreateTagModal,
-    EditTagModal,
+    ManageTagModal,
   } = useTag()
 
   const handleClick = (e: React.MouseEvent, tag: Tag) => {
@@ -45,7 +45,7 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
             <Button
               onClick={e => {
                 e.stopPropagation()
-                handleEditTag(e, tag._id)
+                handleManageTag(e, tag._id)
                 onEditClick()
               }}
               className="btn-transparent edit-btn me-2"
@@ -62,7 +62,7 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
             <span
               className="me-2"
               style={{
-                color: `hsl(${ColorManager.getHueForTagId(tag._id)}, 70%, 45%)`,
+                color: getTagColor(tag),
               }}
             >
               <Icon
@@ -103,8 +103,8 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
           <span>{t('new_folder')}</span>
         </span>
       </MenuItemButton>
-      <CreateTagModal id="create-tag-modal-dropdown" />
-      <EditTagModal id="edit-tag-modal-dropdown" />
+      <CreateTagModal id="create-tag-modal-dropdown" disableCustomColor />
+      <ManageTagModal id="manage-tag-modal-dropdown" />
     </>
   )
 }

+ 59 - 0
services/web/frontend/js/features/project-list/context/color-picker-context.tsx

@@ -0,0 +1,59 @@
+import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
+
+export type ColorPickerContextValue = {
+  selectedColor?: string
+  setSelectedColor: (color?: string) => void
+  showCustomPicker: boolean
+  setShowCustomPicker: (show: boolean) => void
+  pickingCustomColor: boolean
+  setPickingCustomColor: (picking: boolean) => void
+}
+
+export const ColorPickerContext = createContext<
+  ColorPickerContextValue | undefined
+>(undefined)
+
+type ColorPickerProviderProps = {
+  children: ReactNode
+}
+
+export function ColorPickerProvider({ children }: ColorPickerProviderProps) {
+  const [selectedColor, setSelectedColor] = useState<string | undefined>()
+  const [showCustomPicker, setShowCustomPicker] = useState(false)
+  const [pickingCustomColor, setPickingCustomColor] = useState(false)
+
+  const value = useMemo<ColorPickerContextValue>(
+    () => ({
+      pickingCustomColor,
+      selectedColor,
+      setPickingCustomColor,
+      setSelectedColor,
+      setShowCustomPicker,
+      showCustomPicker,
+    }),
+    [
+      pickingCustomColor,
+      selectedColor,
+      setPickingCustomColor,
+      setSelectedColor,
+      setShowCustomPicker,
+      showCustomPicker,
+    ]
+  )
+
+  return (
+    <ColorPickerContext.Provider value={value}>
+      {children}
+    </ColorPickerContext.Provider>
+  )
+}
+
+export function useColorPickerContext() {
+  const context = useContext(ColorPickerContext)
+  if (!context) {
+    throw new Error(
+      'ColorPickerContext is only available inside ColorPickerProvider'
+    )
+  }
+  return context
+}

+ 22 - 13
services/web/frontend/js/features/project-list/context/project-list-context.tsx

@@ -85,7 +85,7 @@ export type ProjectListContextValue = {
   selectedTagId?: string | undefined
   selectTag: (tagId: string) => void
   addTag: (tag: Tag) => void
-  renameTag: (tagId: string, newTagName: string) => void
+  updateTag: (tagId: string, newTagName: string, newTagColor?: string) => void
   deleteTag: (tagId: string) => void
   updateProjectViewData: (newProjectData: Project) => void
   removeProjectFromView: (project: Project) => void
@@ -138,6 +138,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
   const [selectedTagId, setSelectedTagId] = usePersistedState<
     string | undefined
   >('project-list-selected-tag-id', undefined)
+  const [showCustomPicker, setShowCustomPicker] = useState(false)
 
   const olTags: Tag[] = getMeta('ol-tags', [])
 
@@ -320,16 +321,20 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     setTags(tags => uniqBy(concat(tags, [tag]), '_id'))
   }, [])
 
-  const renameTag = useCallback((tagId: string, newTagName: string) => {
-    setTags(tags => {
-      const newTags = cloneDeep(tags)
-      const tag = find(newTags, ['_id', tagId])
-      if (tag) {
-        tag.name = newTagName
-      }
-      return newTags
-    })
-  }, [])
+  const updateTag = useCallback(
+    (tagId: string, newTagName: string, newTagColor?: string) => {
+      setTags(tags => {
+        const newTags = cloneDeep(tags)
+        const tag = find(newTags, ['_id', tagId])
+        if (tag) {
+          tag.name = newTagName
+          tag.color = newTagColor
+        }
+        return newTags
+      })
+    },
+    []
+  )
 
   const deleteTag = useCallback(
     (tagId: string | null) => {
@@ -438,7 +443,6 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       loadProgress,
       removeProjectFromTagInView,
       removeProjectFromView,
-      renameTag,
       selectedTagId,
       selectFilter,
       selectedProjects,
@@ -446,13 +450,16 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       selectTag,
       searchText,
       setSearchText,
+      setShowCustomPicker,
       setSort,
       showAllProjects,
+      showCustomPicker,
       sort,
       tags,
       totalProjectsCount,
       untaggedProjectsCount,
       updateProjectViewData,
+      updateTag,
       projectsPerTag,
       visibleProjects,
     }),
@@ -472,7 +479,6 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       loadProgress,
       removeProjectFromTagInView,
       removeProjectFromView,
-      renameTag,
       selectedTagId,
       selectFilter,
       selectedProjects,
@@ -480,13 +486,16 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       selectTag,
       searchText,
       setSearchText,
+      setShowCustomPicker,
       setSort,
       showAllProjects,
+      showCustomPicker,
       sort,
       tags,
       totalProjectsCount,
       untaggedProjectsCount,
       updateProjectViewData,
+      updateTag,
       projectsPerTag,
       visibleProjects,
     ]

+ 39 - 0
services/web/frontend/js/features/project-list/hooks/use-select-color.ts

@@ -0,0 +1,39 @@
+import { useEffect } from 'react'
+import { useColorPickerContext } from '../context/color-picker-context'
+
+export default function useSelectColor(defaultColor?: string) {
+  const {
+    selectedColor,
+    setSelectedColor,
+    showCustomPicker,
+    setShowCustomPicker,
+    pickingCustomColor,
+    setPickingCustomColor,
+  } = useColorPickerContext()
+
+  useEffect(() => {
+    setSelectedColor(defaultColor)
+  }, [defaultColor, setSelectedColor])
+
+  const selectColor = (color: string) => {
+    setSelectedColor(color)
+  }
+
+  const openCustomPicker = () => {
+    setShowCustomPicker(true)
+  }
+
+  const closeCustomPicker = () => {
+    setShowCustomPicker(false)
+  }
+
+  return {
+    selectedColor,
+    selectColor,
+    showCustomPicker,
+    openCustomPicker,
+    closeCustomPicker,
+    pickingCustomColor,
+    setPickingCustomColor,
+  }
+}

+ 39 - 33
services/web/frontend/js/features/project-list/hooks/use-tag.tsx

@@ -2,9 +2,9 @@ import { useState, useCallback } from 'react'
 import { useProjectListContext } from '../context/project-list-context'
 import { Tag } from '../../../../../app/src/Features/Tags/types'
 import CreateTagModal from '../components/modals/create-tag-modal'
-import RenameTagModal from '../components/modals/rename-tag-modal'
+import { EditTagModal } from '../components/modals/edit-tag-modal'
 import DeleteTagModal from '../components/modals/delete-tag-modal'
-import EditTagModal from '../components/modals/edit-tag-modal'
+import { ManageTagModal } from '../components/modals/manage-tag-modal'
 import { find } from 'lodash'
 import { addProjectsToTag } from '../util/api'
 
@@ -13,15 +13,14 @@ function useTag() {
     tags,
     selectTag,
     addTag,
-    renameTag,
+    updateTag,
     deleteTag,
     selectedProjects,
     addProjectToTagInView,
   } = useProjectListContext()
   const [creatingTag, setCreatingTag] = useState<boolean>(false)
-  const [renamingTag, setRenamingTag] = useState<Tag>()
-  const [deletingTag, setDeletingTag] = useState<Tag>()
   const [editingTag, setEditingTag] = useState<Tag>()
+  const [deletingTag, setDeletingTag] = useState<Tag>()
 
   const handleSelectTag = useCallback(
     (e: React.MouseEvent, tagId: string) => {
@@ -50,23 +49,23 @@ function useTag() {
     [addTag, selectedProjects, addProjectToTagInView]
   )
 
-  const handleRenameTag = useCallback(
+  const handleEditTag = useCallback(
     (e, tagId) => {
       e.preventDefault()
       const tag = find(tags, ['_id', tagId])
       if (tag) {
-        setRenamingTag(tag)
+        setEditingTag(tag)
       }
     },
-    [tags, setRenamingTag]
+    [tags, setEditingTag]
   )
 
-  const onRename = useCallback(
-    (tagId: string, newTagName: string) => {
-      renameTag(tagId, newTagName)
-      setRenamingTag(undefined)
+  const onUpdate = useCallback(
+    (tagId: string, newTagName: string, newTagColor?: string) => {
+      updateTag(tagId, newTagName, newTagColor)
+      setEditingTag(undefined)
     },
-    [renameTag, setRenamingTag]
+    [updateTag, setEditingTag]
   )
 
   const handleDeleteTag = useCallback(
@@ -88,7 +87,7 @@ function useTag() {
     [deleteTag, setDeletingTag]
   )
 
-  const handleEditTag = useCallback(
+  const handleManageTag = useCallback(
     (e, tagId) => {
       e.preventDefault()
       const tag = find(tags, ['_id', tagId])
@@ -99,15 +98,15 @@ function useTag() {
     [tags, setEditingTag]
   )
 
-  const onEditRename = useCallback(
-    (tagId: string, newTagName: string) => {
-      renameTag(tagId, newTagName)
+  const onManageEdit = useCallback(
+    (tagId: string, newTagName: string, newTagColor?: string) => {
+      updateTag(tagId, newTagName, newTagColor)
       setEditingTag(undefined)
     },
-    [renameTag, setEditingTag]
+    [updateTag, setEditingTag]
   )
 
-  const onEditDelete = useCallback(
+  const onManageDelete = useCallback(
     (tagId: string) => {
       deleteTag(tagId)
       setEditingTag(undefined)
@@ -115,24 +114,31 @@ function useTag() {
     [deleteTag, setEditingTag]
   )
 
-  function CreateModal({ id }: { id: string }) {
+  function CreateModal({
+    id,
+    disableCustomColor,
+  }: {
+    id: string
+    disableCustomColor?: boolean
+  }) {
     return (
       <CreateTagModal
         id={id}
         show={creatingTag}
         onCreate={onCreate}
         onClose={() => setCreatingTag(false)}
+        disableCustomColor={disableCustomColor}
       />
     )
   }
 
-  function RenameModal({ id }: { id: string }) {
+  function EditModal({ id }: { id: string }) {
     return (
-      <RenameTagModal
+      <EditTagModal
         id={id}
-        tag={renamingTag}
-        onRename={onRename}
-        onClose={() => setRenamingTag(undefined)}
+        tag={editingTag}
+        onEdit={onUpdate}
+        onClose={() => setEditingTag(undefined)}
       />
     )
   }
@@ -148,13 +154,13 @@ function useTag() {
     )
   }
 
-  function EditModal({ id }: { id: string }) {
+  function ManageModal({ id }: { id: string }) {
     return (
-      <EditTagModal
+      <ManageTagModal
         id={id}
         tag={editingTag}
-        onRename={onEditRename}
-        onDelete={onEditDelete}
+        onEdit={onManageEdit}
+        onDelete={onManageDelete}
         onClose={() => setEditingTag(undefined)}
       />
     )
@@ -163,13 +169,13 @@ function useTag() {
   return {
     handleSelectTag,
     openCreateTagModal,
-    handleRenameTag,
-    handleDeleteTag,
     handleEditTag,
+    handleDeleteTag,
+    handleManageTag,
     CreateTagModal: CreateModal,
-    RenameTagModal: RenameModal,
-    DeleteTagModal: DeleteModal,
     EditTagModal: EditModal,
+    DeleteTagModal: DeleteModal,
+    ManageTagModal: ManageModal,
   }
 }
 

+ 9 - 5
services/web/frontend/js/features/project-list/util/api.ts

@@ -9,15 +9,19 @@ export function getProjects(sortBy: Sort): Promise<GetProjectsResponseBody> {
   return postJSON('/api/project', { body: { sort: sortBy } })
 }
 
-export function createTag(tagName: string): Promise<Tag> {
+export function createTag(name: string, color?: string): Promise<Tag> {
   return postJSON(`/tag`, {
-    body: { name: tagName },
+    body: { name, color },
   })
 }
 
-export function renameTag(tagId: string, newTagName: string) {
-  return postJSON(`/tag/${tagId}/rename`, {
-    body: { name: newTagName },
+export function editTag(
+  tagId: string,
+  newTagName: string,
+  newTagColor?: string
+) {
+  return postJSON(`/tag/${tagId}/edit`, {
+    body: { name: newTagName, color: newTagColor },
   })
 }
 

+ 10 - 0
services/web/frontend/js/features/project-list/util/tag.ts

@@ -1 +1,11 @@
+import { Tag } from '../../../../../app/src/Features/Tags/types'
+import ColorManager from '../../../ide/colors/ColorManager'
+
 export const MAX_TAG_LENGTH = 50
+
+export function getTagColor(tag?: Tag): string | undefined {
+  if (!tag) {
+    return undefined
+  }
+  return tag.color || `hsl(${ColorManager.getHueForTagId(tag._id)}, 70%, 45%)`
+}

+ 17 - 0
services/web/frontend/stories/project-list/color-picker.stories.tsx

@@ -0,0 +1,17 @@
+import { ColorPicker } from '../../js/features/project-list/components/color-picker/color-picker'
+import { ColorPickerProvider } from '../../js/features/project-list/context/color-picker-context'
+
+export const Select = (args: any) => {
+  window.metaAttributesCache = new Map()
+
+  return (
+    <ColorPickerProvider>
+      <ColorPicker {...args} />
+    </ColorPickerProvider>
+  )
+}
+
+export default {
+  title: 'Project List / Color Picker',
+  component: ColorPicker,
+}

+ 92 - 0
services/web/frontend/stylesheets/app/project-list-react.less

@@ -721,6 +721,21 @@
     color: @white;
     background-color: @ol-green;
   }
+
+  .tag-checkbox {
+    width: 16px;
+  }
+
+  .tag-dot {
+    display: inline-block;
+    position: relative;
+    border: 1px solid #ffffff;
+    width: 14px;
+    height: 14px;
+    border-radius: 7px;
+    text-align: center;
+    top: 1px;
+  }
 }
 
 .project-list-sidebar-survey-wrapper {
@@ -757,3 +772,80 @@
     }
   }
 }
+
+.color-picker-item {
+  height: 28px;
+  width: 28px;
+  cursor: pointer;
+  position: relative;
+  outline: none;
+  border-radius: 4px;
+  margin: 0 14px 0 0;
+  display: inline-block;
+  vertical-align: middle;
+
+  &.more-button {
+    border: 1px solid @gray-dark;
+
+    .color-picker-more {
+      color: @gray-dark;
+      margin: 6px 0 0 7px;
+
+      @media (max-width: @screen-xs-max) {
+        margin: 8px 0 0 9px;
+      }
+    }
+
+    .color-picker-more-open {
+      color: @gray-dark;
+      margin: 5px 0 0 5px;
+
+      @media (max-width: @screen-xs-max) {
+        margin: 7px 0 0 7px;
+      }
+    }
+  }
+
+  .color-picker-item-icon {
+    margin: 6px 0 0 6px;
+    color: @white;
+  }
+
+  @media (max-width: @screen-xs-max) {
+    height: 32px;
+    width: 32px;
+    margin: 24px 24px;
+
+    .color-picker-item-icon {
+      margin: 8px 0 0 8px;
+      color: @white;
+    }
+  }
+}
+
+.color-picker-more-wrapper {
+  position: relative;
+  display: inline-block;
+
+  .custom-picker {
+    position: absolute;
+
+    @media (max-width: @screen-xs-max) {
+      top: 56px;
+      left: 24px;
+    }
+  }
+
+  .custom-picker when(@is-new-css = false) {
+    // to prevent the primary button from overlapping when disabled due to the opacity property
+    z-index: 100 !important;
+  }
+
+  .popover-backdrop {
+    position: fixed;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+  }
+}

+ 2 - 0
services/web/locales/en.json

@@ -508,6 +508,7 @@
   "first_name": "First Name",
   "first_x_days_free_after_that_y_per_month": "First <0>__trialLen__ days free</0>, after that <0>__price__</0> per month",
   "fold_line": "Fold line",
+  "folder_color": "Folder color",
   "folders": "Folders",
   "following_paths_conflict": "The following files and folders conflict with the same path",
   "font_family": "Font Family",
@@ -1276,6 +1277,7 @@
   "ru": "Russian",
   "saml": "SAML",
   "saml_create_admin_instructions": "Choose an email address for the first __appName__ admin account. This should correspond to an account in the SAML system. You will then be asked to log in with this account.",
+  "save": "Save",
   "save_20_percent_by_paying_annually": "Save 20% by paying annually",
   "save_or_cancel-cancel": "Cancel",
   "save_or_cancel-or": "or",

+ 2 - 0
services/web/package.json

@@ -220,6 +220,7 @@
     "react": "^17.0.2",
     "react-bootstrap": "^0.33.1",
     "react-chartjs-2": "^5.0.1",
+    "react-color": "^2.19.3",
     "react-dnd": "^11.1.3",
     "react-dnd-html5-backend": "^11.1.3",
     "react-dom": "^17.0.2",
@@ -266,6 +267,7 @@
     "@types/mocha-each": "^2.0.0",
     "@types/react": "^17.0.40",
     "@types/react-bootstrap": "^0.32.29",
+    "@types/react-color": "^3.0.6",
     "@types/react-dom": "^17.0.13",
     "@types/recurly__recurly-js": "^4.22.0",
     "@types/sinon-chai": "^3.2.8",

+ 4 - 4
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -799,9 +799,9 @@ describe('<ProjectListRoot />', function () {
             ).findByText<HTMLElement>('More')
             fireEvent.click(moreDropdown)
 
-            const renameButton =
-              screen.getAllByText<HTMLButtonElement>('Rename')[1] // first one is for the tag in the sidebar
-            fireEvent.click(renameButton)
+            const editButton =
+              screen.getAllByText<HTMLButtonElement>('Rename')[0]
+            fireEvent.click(editButton)
 
             const modals = await screen.findAllByRole('dialog')
             const modal = modals[0]
@@ -837,7 +837,7 @@ describe('<ProjectListRoot />', function () {
             fireEvent.click(moreDropdown)
 
             const renameButton =
-              within(actionsToolbar).getByText<HTMLButtonElement>('Rename') // first one is for the tag in the sidebar
+              within(actionsToolbar).getByText<HTMLButtonElement>('Rename')
             fireEvent.click(renameButton)
 
             const modals = await screen.findAllByRole('dialog')

+ 30 - 24
services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx

@@ -28,7 +28,7 @@ describe('<TagsList />', function () {
       project_ids: [],
     })
     fetchMock.post('express:/tag/:tagId/projects', 200)
-    fetchMock.post('express:/tag/:tagId/rename', 200)
+    fetchMock.post('express:/tag/:tagId/edit', 200)
     fetchMock.delete('express:/tag/:tagId', 200)
 
     renderWithProjectListContext(<TagsList />)
@@ -79,7 +79,7 @@ describe('<TagsList />', function () {
     )
   })
 
-  describe('create modal', function () {
+  describe('Create modal', function () {
     beforeEach(async function () {
       const newTagButton = screen.getByRole('button', {
         name: 'New Folder',
@@ -156,22 +156,22 @@ describe('<TagsList />', function () {
     })
   })
 
-  describe('rename modal', function () {
+  describe('Edit modal', function () {
     beforeEach(async function () {
       const tag1Button = screen.getByText('Tag 1')
 
-      const renameButton = within(
+      const editButton = within(
         tag1Button.closest('li') as HTMLElement
       ).getByRole('button', {
-        name: 'Rename',
+        name: 'Edit',
       })
 
-      await fireEvent.click(renameButton)
+      await fireEvent.click(editButton)
     })
 
     it('modal is open', async function () {
       const modal = screen.getAllByRole('dialog', { hidden: false })[0]
-      within(modal).getByRole('heading', { name: 'Rename Folder' })
+      within(modal).getByRole('heading', { name: 'Edit Folder' })
     })
 
     it('click on cancel closes the modal', async function () {
@@ -182,14 +182,20 @@ describe('<TagsList />', function () {
       expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
     })
 
-    it('Rename button is disabled when input is empty', async function () {
+    it('Save button is disabled when input is empty', async function () {
       const modal = screen.getAllByRole('dialog', { hidden: false })[0]
-      const renameButton = within(modal).getByRole('button', { name: 'Rename' })
+      const input = within(modal).getByRole('textbox')
+      fireEvent.change(input, {
+        target: {
+          value: '',
+        },
+      })
+      const saveButton = within(modal).getByRole('button', { name: 'Save' })
 
-      expect(renameButton.hasAttribute('disabled')).to.be.true
+      expect(saveButton.hasAttribute('disabled')).to.be.true
     })
 
-    it('Rename button is disabled with error message when tag name is too long', async function () {
+    it('Save button is disabled with error message when tag name is too long', async function () {
       const modal = screen.getAllByRole('dialog', { hidden: false })[0]
       const input = within(modal).getByRole('textbox')
       fireEvent.change(input, {
@@ -198,18 +204,18 @@ describe('<TagsList />', function () {
         },
       })
 
-      const createButton = within(modal).getByRole('button', { name: 'Rename' })
-      expect(createButton.hasAttribute('disabled')).to.be.true
+      const saveButton = within(modal).getByRole('button', { name: 'Save' })
+      expect(saveButton.hasAttribute('disabled')).to.be.true
       screen.getByText('Tag name cannot exceed 50 characters')
     })
 
-    it('Rename button is disabled with no error message when tag name is unchanged', async function () {
+    it('Save button is disabled with no error message when tag name is unchanged', async function () {
       const modal = screen.getAllByRole('dialog', { hidden: false })[0]
-      const createButton = within(modal).getByRole('button', { name: 'Rename' })
-      expect(createButton.hasAttribute('disabled')).to.be.true
+      const saveButton = within(modal).getByRole('button', { name: 'Save' })
+      expect(saveButton.hasAttribute('disabled')).to.be.true
     })
 
-    it('Rename button is disabled with error message when tag name is already used', async function () {
+    it('Save button is disabled with error message when tag name is already used', async function () {
       const modal = screen.getAllByRole('dialog', { hidden: false })[0]
       const input = within(modal).getByRole('textbox')
       fireEvent.change(input, {
@@ -218,20 +224,20 @@ describe('<TagsList />', function () {
         },
       })
 
-      const createButton = within(modal).getByRole('button', { name: 'Rename' })
-      expect(createButton.hasAttribute('disabled')).to.be.true
+      const saveButton = within(modal).getByRole('button', { name: 'Save' })
+      expect(saveButton.hasAttribute('disabled')).to.be.true
       screen.getByText('Tag "Another tag" already exists')
     })
 
-    it('filling the input and clicking Rename sends a request', async function () {
+    it('filling the input and clicking Save sends a request', async function () {
       const modal = screen.getAllByRole('dialog', { hidden: false })[0]
       const input = within(modal).getByRole('textbox')
       fireEvent.change(input, { target: { value: 'New Tag Name' } })
 
-      const renameButton = within(modal).getByRole('button', { name: 'Rename' })
-      expect(renameButton.hasAttribute('disabled')).to.be.false
+      const saveButton = within(modal).getByRole('button', { name: 'Save' })
+      expect(saveButton.hasAttribute('disabled')).to.be.false
 
-      await fireEvent.click(renameButton)
+      await fireEvent.click(saveButton)
 
       await waitFor(() => expect(fetchMock.called(`/tag/abc123def456/rename`)))
 
@@ -243,7 +249,7 @@ describe('<TagsList />', function () {
     })
   })
 
-  describe('delete modal', function () {
+  describe('Delete modal', function () {
     beforeEach(async function () {
       const tag1Button = screen.getByText('Another tag')
 

+ 6 - 1
services/web/test/frontend/features/project-list/helpers/render-with-context.tsx

@@ -1,6 +1,7 @@
 import { render } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import React from 'react'
+import { ColorPickerProvider } from '../../../../../frontend/js/features/project-list/context/color-picker-context'
 import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
 import { Project } from '../../../../../types/project/dashboard/api'
 import { projectsData } from '../fixtures/projects-data'
@@ -33,7 +34,11 @@ export function renderWithProjectListContext(
     children,
   }: {
     children: React.ReactNode
-  }) => <ProjectListProvider>{children}</ProjectListProvider>
+  }) => (
+    <ProjectListProvider>
+      <ColorPickerProvider>{children}</ColorPickerProvider>
+    </ProjectListProvider>
+  )
 
   return render(component, {
     wrapper: ProjectListProviderWrapper,

+ 74 - 22
services/web/test/unit/src/Tags/TagsControllerTests.js

@@ -18,6 +18,7 @@ describe('TagsController', function () {
         removeProjectFromTag: sinon.stub().resolves(),
         removeProjectsFromTag: sinon.stub().resolves(),
         deleteTag: sinon.stub().resolves(),
+        editTag: sinon.stub().resolves(),
         renameTag: sinon.stub().resolves(),
         createTag: sinon.stub().resolves(),
       },
@@ -66,23 +67,49 @@ describe('TagsController', function () {
     })
   })
 
-  it('create a tag', function (done) {
-    this.tag = { mock: 'tag' }
-    this.TagsHandler.promises.createTag = sinon.stub().resolves(this.tag)
-    this.req.session.user._id = this.userId = 'user-id-123'
-    this.req.body = { name: (this.name = 'tag-name') }
-    this.TagsController.createTag(this.req, {
-      json: () => {
-        sinon.assert.calledWith(
-          this.TagsHandler.promises.createTag,
-          this.userId,
-          this.name
-        )
-        done()
-        return {
-          end: () => {},
-        }
-      },
+  describe('create a tag', function (done) {
+    it('without a color', function (done) {
+      this.tag = { mock: 'tag' }
+      this.TagsHandler.promises.createTag = sinon.stub().resolves(this.tag)
+      this.req.session.user._id = this.userId = 'user-id-123'
+      this.req.body = { name: (this.name = 'tag-name') }
+      this.TagsController.createTag(this.req, {
+        json: () => {
+          sinon.assert.calledWith(
+            this.TagsHandler.promises.createTag,
+            this.userId,
+            this.name
+          )
+          done()
+          return {
+            end: () => {},
+          }
+        },
+      })
+    })
+
+    it('with a color', function (done) {
+      this.tag = { mock: 'tag' }
+      this.TagsHandler.promises.createTag = sinon.stub().resolves(this.tag)
+      this.req.session.user._id = this.userId = 'user-id-123'
+      this.req.body = {
+        name: (this.name = 'tag-name'),
+        color: (this.color = '#123456'),
+      }
+      this.TagsController.createTag(this.req, {
+        json: () => {
+          sinon.assert.calledWith(
+            this.TagsHandler.promises.createTag,
+            this.userId,
+            this.name,
+            this.color
+          )
+          done()
+          return {
+            end: () => {},
+          }
+        },
+      })
     })
   })
 
@@ -105,19 +132,21 @@ describe('TagsController', function () {
     })
   })
 
-  describe('rename a tag', function () {
+  describe('edit a tag', function () {
     beforeEach(function () {
       this.req.params.tagId = this.tagId = 'tag-id-123'
       this.req.session.user._id = this.userId = 'user-id-123'
     })
 
-    it('with a name', function (done) {
-      this.req.body = { name: (this.name = 'new-name') }
-      this.TagsController.renameTag(this.req, {
+    it('with a name and no color', function (done) {
+      this.req.body = {
+        name: (this.name = 'new-name'),
+      }
+      this.TagsController.editTag(this.req, {
         status: code => {
           assert.equal(code, 204)
           sinon.assert.calledWith(
-            this.TagsHandler.promises.renameTag,
+            this.TagsHandler.promises.editTag,
             this.userId,
             this.tagId,
             this.name
@@ -130,6 +159,29 @@ describe('TagsController', function () {
       })
     })
 
+    it('with a name and color', function (done) {
+      this.req.body = {
+        name: (this.name = 'new-name'),
+        color: (this.color = '#FF0011'),
+      }
+      this.TagsController.editTag(this.req, {
+        status: code => {
+          assert.equal(code, 204)
+          sinon.assert.calledWith(
+            this.TagsHandler.promises.editTag,
+            this.userId,
+            this.tagId,
+            this.name,
+            this.color
+          )
+          done()
+          return {
+            end: () => {},
+          }
+        },
+      })
+    })
+
     it('without a name', function (done) {
       this.req.body = { name: undefined }
       this.TagsController.renameTag(this.req, {

+ 6 - 1
services/web/test/unit/src/Tags/TagsHandlerTests.js

@@ -13,7 +13,7 @@ describe('TagsHandler', function () {
     this.userId = ObjectId().toString()
     this.callback = sinon.stub()
 
-    this.tag = { user_id: this.userId, name: 'some name' }
+    this.tag = { user_id: this.userId, name: 'some name', color: '#3399CC' }
     this.tagId = ObjectId().toString()
     this.projectId = ObjectId().toString()
 
@@ -54,11 +54,13 @@ describe('TagsHandler', function () {
         this.TagsHandler.createTag(
           this.tag.user_id,
           this.tag.name,
+          this.tag.color,
           (err, resultTag) => {
             expect(err).to.not.exist
             this.TagMock.verify()
             expect(resultTag.user_id).to.equal(this.tag.user_id)
             expect(resultTag.name).to.equal(this.tag.name)
+            expect(resultTag.color).to.equal(this.tag.color)
             done()
           }
         )
@@ -70,6 +72,7 @@ describe('TagsHandler', function () {
         this.TagsHandler.createTag(
           this.tag.user_id,
           'this is a tag that is very very very very very very long',
+          undefined,
           err => {
             expect(err.message).to.equal('Exceeded max tag length')
             done()
@@ -96,11 +99,13 @@ describe('TagsHandler', function () {
         this.TagsHandler.createTag(
           this.tag.user_id,
           this.tag.name,
+          this.tag.color,
           (err, resultTag) => {
             expect(err).to.not.exist
             this.TagMock.verify()
             expect(resultTag.user_id).to.equal(this.tag.user_id)
             expect(resultTag.name).to.equal(this.tag.name)
+            expect(resultTag.color).to.equal(this.tag.color)
             done()
           }
         )