[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-13 08:04:03 +00:00
committed by Copybot
parent fb6746a887
commit 04c204f989
32 changed files with 1029 additions and 359 deletions
@@ -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()
}
)