mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-19 20:43:11 +00:00
[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
This commit is contained in:
parent
fb6746a887
commit
04c204f989
32 changed files with 1029 additions and 359 deletions
123
package-lock.json
generated
123
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
export type Tag = {
|
||||
_id: string
|
||||
user_id: string
|
||||
name: string | null
|
||||
name: string
|
||||
color?: string
|
||||
project_ids?: string[]
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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 />}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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')} …</>
|
||||
) : (
|
||||
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')} …</>
|
||||
) : (
|
||||
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')} …</> : t('save')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
|
|
|
@ -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')} …</>
|
||||
) : (
|
||||
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')} …</>
|
||||
) : (
|
||||
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>
|
||||
)
|
||||
}
|
|
@ -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')} …</> : t('rename')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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,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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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%)`
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue