From 04c204f98990e99ca29fe2012c4cd6a32cccc74c Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Wed, 12 Apr 2023 10:30:56 +0200 Subject: [PATCH] [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 --- package-lock.json | 123 +++++++++++++ .../app/src/Features/Tags/TagsController.js | 17 +- .../web/app/src/Features/Tags/TagsHandler.js | 24 ++- services/web/app/src/Features/Tags/types.d.ts | 3 +- services/web/app/src/models/Tag.js | 11 ++ services/web/app/src/router.js | 13 ++ .../web/frontend/extracted-translations.json | 4 +- .../components/color-picker/color-picker.tsx | 133 ++++++++++++++ .../components/modals/create-tag-modal.tsx | 37 ++-- .../components/modals/edit-tag-modal.tsx | 173 +++++++++--------- .../components/modals/manage-tag-modal.tsx | 155 ++++++++++++++++ .../components/modals/rename-tag-modal.tsx | 132 ------------- .../components/project-list-root.tsx | 5 +- .../components/sidebar/tags-list.tsx | 18 +- .../components/table/cells/inline-tags.tsx | 4 +- .../project-tools/buttons/tags-dropdown.tsx | 8 + .../project-list/components/tags-list.tsx | 14 +- .../context/color-picker-context.tsx | 59 ++++++ .../context/project-list-context.tsx | 35 ++-- .../project-list/hooks/use-select-color.ts | 39 ++++ .../features/project-list/hooks/use-tag.tsx | 72 ++++---- .../js/features/project-list/util/api.ts | 14 +- .../js/features/project-list/util/tag.ts | 10 + .../project-list/color-picker.stories.tsx | 17 ++ .../stylesheets/app/project-list-react.less | 92 ++++++++++ services/web/locales/en.json | 2 + services/web/package.json | 2 + .../components/project-list-root.test.tsx | 8 +- .../components/sidebar/tags-list.test.tsx | 54 +++--- .../helpers/render-with-context.tsx | 7 +- .../test/unit/src/Tags/TagsControllerTests.js | 96 +++++++--- .../test/unit/src/Tags/TagsHandlerTests.js | 7 +- 32 files changed, 1029 insertions(+), 359 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/color-picker/color-picker.tsx create mode 100644 services/web/frontend/js/features/project-list/components/modals/manage-tag-modal.tsx delete mode 100644 services/web/frontend/js/features/project-list/components/modals/rename-tag-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/context/color-picker-context.tsx create mode 100644 services/web/frontend/js/features/project-list/hooks/use-select-color.ts create mode 100644 services/web/frontend/stories/project-list/color-picker.stories.tsx diff --git a/package-lock.json b/package-lock.json index e3eca81898..a8f12a9da6 100644 --- a/package-lock.json +++ b/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", diff --git a/services/web/app/src/Features/Tags/TagsController.js b/services/web/app/src/Features/Tags/TagsController.js index ac2ef36684..6db9290a77 100644 --- a/services/web/app/src/Features/Tags/TagsController.js +++ b/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), } diff --git a/services/web/app/src/Features/Tags/TagsHandler.js b/services/web/app/src/Features/Tags/TagsHandler.js index 50bbd02390..5c82281b85 100644 --- a/services/web/app/src/Features/Tags/TagsHandler.js +++ b/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, diff --git a/services/web/app/src/Features/Tags/types.d.ts b/services/web/app/src/Features/Tags/types.d.ts index 3886cf0424..17a43fd4a8 100644 --- a/services/web/app/src/Features/Tags/types.d.ts +++ b/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[] } diff --git a/services/web/app/src/models/Tag.js b/services/web/app/src/models/Tag.js index 83eeb156bc..8e0eb115dc 100644 --- a/services/web/app/src/models/Tag.js +++ b/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 } diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index a4075022f8..aac72708d7 100644 --- a/services/web/app/src/router.js +++ b/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(), diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index fe85a1caf6..9e04a45306 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/project-list/components/color-picker/color-picker.tsx b/services/web/frontend/js/features/project-list/components/color-picker/color-picker.tsx new file mode 100644 index 0000000000..35890ac799 --- /dev/null +++ b/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 = [ + '#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 */ +
selectColor(color)} + > + {!pickingCustomColor && color === selectedColor && ( + + )} +
+ ) +} + +function MoreButton() { + const { + selectedColor, + selectColor, + showCustomPicker, + openCustomPicker, + closeCustomPicker, + setPickingCustomColor, + } = useSelectColor() + const [localColor, setLocalColor] = useState() + + useEffect(() => { + setLocalColor(selectedColor) + }, [selectedColor]) + + const isCustomColorSelected = + localColor && !PRESET_COLORS.includes(localColor) + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions, jsx-a11y/interactive-supports-focus */} +
+ {isCustomColorSelected ? ( + + ) : showCustomPicker ? ( + + ) : ( + + )} +
+ {showCustomPicker && ( + <> + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions, jsx-a11y/interactive-supports-focus */} +
closeCustomPicker()} + /> + { + setPickingCustomColor(true) + setLocalColor(color.hex) + }} + onChangeComplete={color => { + selectColor(color.hex) + setPickingCustomColor(false) + }} + color={localColor} + className="custom-picker" + /> + + )} +
+ ) +} + +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 => ( + + ))} + {!disableCustomColor && } + + ) +} diff --git a/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx index daa177aec5..4c2040fc13 100644 --- a/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx +++ b/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() const { autoFocusedRef } = useRefWithAutoFocus() @@ -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({
- setTagName(e.target.value)} - /> + + setTagName(e.target.value)} + /> + +
diff --git a/services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx index 83dab78d98..a28d281671 100644 --- a/services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx +++ b/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() - const { - isLoading: isDeleteLoading, - isError: isDeleteError, - runAsync: runDeleteAsync, - } = useAsync() - const { - isLoading: isRenameLoading, - isError: isRenameError, - runAsync: runRenameAsync, - } = useAsync() - const [newTagName, setNewTagName] = useState() - const runDeleteTag = useCallback( - (tagId: string) => { - runDeleteAsync(deleteTag(tagId)) - .then(() => { - onDelete(tagId) - }) - .catch(console.error) - }, - [runDeleteAsync, onDelete] - ) + const [newTagName, setNewTagName] = useState() + const [validationError, setValidationError] = useState() - 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({ -
- setNewTagName(e.target.value)} - /> + + + setNewTagName(e.target.value)} + /> + +
-
+ {validationError && (
- + {validationError}
- - -
- {(isDeleteError || isRenameError) && ( -
+ )} + {isError && ( +
{t('generic_something_went_wrong')}
)} + + ) diff --git a/services/web/frontend/js/features/project-list/components/modals/manage-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/manage-tag-modal.tsx new file mode 100644 index 0000000000..f337dd352b --- /dev/null +++ b/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() + const { + isLoading: isDeleteLoading, + isError: isDeleteError, + runAsync: runDeleteAsync, + } = useAsync() + const { + isLoading: isUpdateLoading, + isError: isRenameError, + runAsync: runEditAsync, + } = useAsync() + const [newTagName, setNewTagName] = useState(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 ( + + + {t('edit_folder')} + + + +
+ + setNewTagName(e.target.value)} + /> + + +
+
+ + +
+
+ +
+ + +
+ {(isDeleteError || isRenameError) && ( +
+ + {t('generic_something_went_wrong')} + +
+ )} +
+
+ ) +} diff --git a/services/web/frontend/js/features/project-list/components/modals/rename-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/rename-tag-modal.tsx deleted file mode 100644 index 3db7260daf..0000000000 --- a/services/web/frontend/js/features/project-list/components/modals/rename-tag-modal.tsx +++ /dev/null @@ -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() - - const [newTagName, setNewTagName] = useState() - const [validationError, setValidationError] = useState() - - 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 ( - - - {t('rename_folder')} - - - -
- setNewTagName(e.target.value)} - /> -
-
- - - {validationError && ( -
- {validationError} -
- )} - {isError && ( -
- - {t('generic_something_went_wrong')} - -
- )} - - -
-
- ) -} diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx index a684ace0fc..2640df6646 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx +++ b/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 ? ( - + + + ) : null } diff --git a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx b/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx index 863f1fdb65..543c53e223 100644 --- a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx +++ b/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() { >
  • @@ -121,8 +119,8 @@ export default function TagsList() {
  • - - + + ) } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx b/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx index c8cbea0128..767cf3e2bf 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx +++ b/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) { >