[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:
Alexandre Bourdin 2023-04-12 10:30:56 +02:00 committed by Copybot
parent fb6746a887
commit 04c204f989
32 changed files with 1029 additions and 359 deletions

123
package-lock.json generated
View file

@ -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",

View file

@ -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),
}

View file

@ -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,

View file

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

View file

@ -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 }

View file

@ -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(),

View file

@ -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": "",

View file

@ -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 />}
</>
)
}

View file

@ -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>

View file

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

View file

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

View file

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

View file

@ -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
}

View file

@ -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" />
</>
)
}

View file

@ -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" />

View file

@ -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>

View file

@ -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" />
</>
)
}

View file

@ -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
}

View file

@ -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,
]

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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 },
})
}

View file

@ -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%)`
}

View file

@ -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,
}

View file

@ -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;
}
}

View file

@ -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",

View file

@ -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",

View file

@ -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')

View file

@ -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')

View file

@ -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,

View file

@ -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, {

View file

@ -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()
}
)