From 9c230f448e508081a31c59e1463e7ae504c7f6d9 Mon Sep 17 00:00:00 2001 From: Emilien Chauvet Date: Thu, 20 Jul 2023 16:45:43 -0700 Subject: [PATCH] Feat/rename and color picker (#780) * WIP * Add menu for rename/color select * Add stories * Remove useless code * Fix color name, add icon for selected color * Remove useless comment * Unify color vocabulary * Fix rebase * Rename story * Improve hotkeys and imports --- front/src/generated/graphql.tsx | 11 ++- .../pipeline/components/EntityBoardColumn.tsx | 15 ++- front/src/modules/pipeline/queries/update.ts | 5 +- .../ui/board/components/BoardColumn.tsx | 81 ++++++++++----- .../components/BoardColumnEditTitleMenu.tsx | 99 +++++++++++++++++++ .../ui/board/components/BoardColumnMenu.tsx | 68 +++++++++++++ .../board/components/EditColumnTitleInput.tsx | 64 ------------ .../BoardColumnEditTitleMenu.story.tsx | 28 ++++++ .../ui/board/types/BoardColumnHotkeyScope.ts | 3 + .../components/DropdownButton.tsx | 2 +- front/src/modules/ui/tag/components/Tag.tsx | 41 ++++++++ .../components/__stories__/Tag.stories.tsx | 36 +++++++ front/src/modules/ui/themes/colors.ts | 4 +- front/src/modules/ui/themes/tag.ts | 55 +++++++++++ front/src/modules/ui/themes/themes.ts | 3 + server/src/database/schema.prisma | 3 + 16 files changed, 415 insertions(+), 103 deletions(-) create mode 100644 front/src/modules/ui/board/components/BoardColumnEditTitleMenu.tsx create mode 100644 front/src/modules/ui/board/components/BoardColumnMenu.tsx delete mode 100644 front/src/modules/ui/board/components/EditColumnTitleInput.tsx create mode 100644 front/src/modules/ui/board/components/__stories__/BoardColumnEditTitleMenu.story.tsx create mode 100644 front/src/modules/ui/board/types/BoardColumnHotkeyScope.ts create mode 100644 front/src/modules/ui/tag/components/Tag.tsx create mode 100644 front/src/modules/ui/tag/components/__stories__/Tag.stories.tsx create mode 100644 front/src/modules/ui/themes/tag.ts diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 819703987..ad1149913 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -2185,11 +2185,11 @@ export type DeleteManyPipelineProgressMutation = { __typename?: 'Mutation', dele export type UpdatePipelineStageMutationVariables = Exact<{ id?: InputMaybe; - name?: InputMaybe; + data: PipelineStageUpdateInput; }>; -export type UpdatePipelineStageMutation = { __typename?: 'Mutation', updateOnePipelineStage?: { __typename?: 'PipelineStage', id: string, name: string } | null }; +export type UpdatePipelineStageMutation = { __typename?: 'Mutation', updateOnePipelineStage?: { __typename?: 'PipelineStage', id: string, name: string, color: string } | null }; export type SearchPeopleQueryVariables = Exact<{ where?: InputMaybe; @@ -3957,10 +3957,11 @@ export type DeleteManyPipelineProgressMutationHookResult = ReturnType; export type DeleteManyPipelineProgressMutationOptions = Apollo.BaseMutationOptions; export const UpdatePipelineStageDocument = gql` - mutation UpdatePipelineStage($id: String, $name: String) { - updateOnePipelineStage(where: {id: $id}, data: {name: $name}) { + mutation UpdatePipelineStage($id: String, $data: PipelineStageUpdateInput!) { + updateOnePipelineStage(where: {id: $id}, data: $data) { id name + color } } `; @@ -3980,7 +3981,7 @@ export type UpdatePipelineStageMutationFn = Apollo.MutationFunction {(droppableProvided) => ( ` background-color: ${({ theme }) => theme.background.primary}; @@ -29,10 +34,14 @@ const StyledHeader = styled.div` width: 100%; `; -export const StyledColumnTitle = styled.h3` +export const StyledColumnTitle = styled.h3<{ + colorHexCode?: string; + colorName?: string; +}>` align-items: center; border-radius: ${({ theme }) => theme.border.radius.sm}; - color: ${({ color }) => color}; + color: ${({ colorHexCode, colorName, theme }) => + colorName ? theme.tag.text[colorName] : colorHexCode}; display: flex; flex-direction: row; font-size: ${({ theme }) => theme.font.size.md}; @@ -52,49 +61,67 @@ const StyledAmount = styled.div` `; type OwnProps = { - colorCode?: string; + color?: string; title: string; pipelineStageId?: string; onTitleEdit: (title: string) => void; + onColumnColorEdit: (color: string) => void; totalAmount?: number; children: React.ReactNode; isFirstColumn: boolean; }; export function BoardColumn({ - colorCode, + color, title, onTitleEdit, + onColumnColorEdit, totalAmount, children, isFirstColumn, }: OwnProps) { - const [isEditing, setIsEditing] = React.useState(false); - const [internalValue, setInternalValue] = React.useState(title); + const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = + React.useState(false); - const debouncedOnUpdate = debounce(onTitleEdit, 200); - const handleChange = (event: ChangeEvent) => { - setInternalValue(event.target.value); - debouncedOnUpdate(event.target.value); - }; + const { + setHotkeyScopeAndMemorizePreviousScope, + goBackToPreviousHotkeyScope, + } = usePreviousHotkeyScope(); + + useScopedHotkeys( + [Key.Escape, Key.Enter], + handleClose, + BoardColumnHotkeyScope.BoardColumn, + [], + ); + + function handleTitleClick() { + setIsBoardColumnMenuOpen(true); + setHotkeyScopeAndMemorizePreviousScope(BoardColumnHotkeyScope.BoardColumn, { + goto: false, + }); + } + + function handleClose() { + goBackToPreviousHotkeyScope(); + setIsBoardColumnMenuOpen(false); + } return ( - setIsEditing(true)}> - - {isEditing ? ( - setIsEditing(false)} - value={internalValue} - onChange={handleChange} - /> - ) : ( -
{title}
- )} -
+ + {!!totalAmount && ${totalAmount}} + {isBoardColumnMenuOpen && ( + setIsBoardColumnMenuOpen(false)} + onTitleEdit={onTitleEdit} + onColumnColorEdit={onColumnColorEdit} + title={title} + color={color} + /> + )} {children}
); diff --git a/front/src/modules/ui/board/components/BoardColumnEditTitleMenu.tsx b/front/src/modules/ui/board/components/BoardColumnEditTitleMenu.tsx new file mode 100644 index 000000000..cf0ba8339 --- /dev/null +++ b/front/src/modules/ui/board/components/BoardColumnEditTitleMenu.tsx @@ -0,0 +1,99 @@ +import { ChangeEvent, useState } from 'react'; +import styled from '@emotion/styled'; + +import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSelectableItem } from '@/ui/dropdown/components/DropdownMenuSelectableItem'; +import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator'; +import { textInputStyle } from '@/ui/themes/effects'; +import { debounce } from '~/utils/debounce'; + +export const StyledEditTitleContainer = styled.div` + --vertical-padding: ${({ theme }) => theme.spacing(1)}; + + align-items: center; + + display: flex; + flex-direction: row; + height: calc(36px - 2 * var(--vertical-padding)); + padding: var(--vertical-padding) 0; + + width: calc(100%); +`; + +const StyledEditModeInput = styled.input` + font-size: ${({ theme }) => theme.font.size.sm}; + + ${textInputStyle} + + width: 100%; +`; + +type OwnProps = { + onClose: () => void; + title: string; + onTitleEdit: (title: string) => void; + onColumnColorEdit: (color: string) => void; + color?: string; +}; + +const StyledColorSample = styled.div<{ colorName: string }>` + background-color: ${({ theme, colorName }) => + theme.tag.background[colorName]}; + border: 1px solid ${({ theme, colorName }) => theme.color[colorName]}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + height: 12px; + width: 12px; +`; + +const COLOR_OPTIONS = [ + { name: 'Green', id: 'green' }, + { name: 'Turquoise', id: 'turquoise' }, + { name: 'Sky', id: 'sky' }, + { name: 'Blue', id: 'blue' }, + { name: 'Purple', id: 'purple' }, + { name: 'Pink', id: 'pink' }, + { name: 'Red', id: 'red' }, + { name: 'Orange', id: 'orange' }, + { name: 'Yellow', id: 'yellow' }, + { name: 'Gray', id: 'gray' }, +]; + +export function BoardColumnEditTitleMenu({ + onClose, + onTitleEdit, + onColumnColorEdit, + title, + color, +}: OwnProps) { + const [internalValue, setInternalValue] = useState(title); + const debouncedOnUpdate = debounce(onTitleEdit, 200); + const handleChange = (event: ChangeEvent) => { + setInternalValue(event.target.value); + debouncedOnUpdate(event.target.value); + }; + return ( + + + + + + {COLOR_OPTIONS.map((colorOption) => ( + { + onColumnColorEdit(colorOption.id); + onClose(); + }} + selected={colorOption.id === color} + > + + {colorOption.name} + + ))} + + ); +} diff --git a/front/src/modules/ui/board/components/BoardColumnMenu.tsx b/front/src/modules/ui/board/components/BoardColumnMenu.tsx new file mode 100644 index 000000000..a733b6a5f --- /dev/null +++ b/front/src/modules/ui/board/components/BoardColumnMenu.tsx @@ -0,0 +1,68 @@ +import { useRef, useState } from 'react'; +import styled from '@emotion/styled'; +import { IconPencil } from '@tabler/icons-react'; + +import { icon } from '@/ui//themes/icon'; +import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu'; +import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSelectableItem } from '@/ui/dropdown/components/DropdownMenuSelectableItem'; +import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton'; +import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef'; + +import { BoardColumnEditTitleMenu } from './BoardColumnEditTitleMenu'; + +const StyledMenuContainer = styled.div` + position: absolute; + width: 200px; + z-index: 1; +`; + +type OwnProps = { + onClose: () => void; + title: string; + color?: string; + onTitleEdit: (title: string) => void; + onColumnColorEdit: (color: string) => void; +}; + +export function BoardColumnMenu({ + onClose, + onTitleEdit, + onColumnColorEdit, + title, + color, +}: OwnProps) { + const [openMenu, setOpenMenu] = useState('actions'); + const boardColumnMenuRef = useRef(null); + + useListenClickOutsideArrayOfRef({ + refs: [boardColumnMenuRef], + callback: onClose, + }); + + return ( + + + {openMenu === 'actions' && ( + + setOpenMenu('title')}> + + + + Rename + + + )} + {openMenu === 'title' && ( + + )} + + + ); +} diff --git a/front/src/modules/ui/board/components/EditColumnTitleInput.tsx b/front/src/modules/ui/board/components/EditColumnTitleInput.tsx deleted file mode 100644 index cf8d4555e..000000000 --- a/front/src/modules/ui/board/components/EditColumnTitleInput.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; - -import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef'; -import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys'; -import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope'; - -import { ColumnHotkeyScope } from './ColumnHotkeyScope'; - -const StyledEditTitleInput = styled.input` - background-color: transparent; - border: none; - color: ${({ color }) => color}; - font-family: ${({ theme }) => theme.font.family}; - font-size: ${({ theme }) => theme.font.size.md}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - - &::placeholder { - color: ${({ theme }) => theme.font.color.light}; - font-family: ${({ theme }) => theme.font.family}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - } - font-weight: ${({ theme }) => theme.font.weight.medium}; - - margin: 0; - outline: none; - padding: 0; -`; - -export function EditColumnTitleInput({ - color, - value, - onChange, - onFocusLeave, -}: { - color?: string; - value: string; - onChange: (event: React.ChangeEvent) => void; - onFocusLeave: () => void; -}) { - const inputRef = React.useRef(null); - - useListenClickOutsideArrayOfRef({ - refs: [inputRef], - callback: () => { - onFocusLeave(); - }, - }); - const setHotkeyScope = useSetHotkeyScope(); - setHotkeyScope(ColumnHotkeyScope.EditColumnName, { goto: false }); - - useScopedHotkeys('enter', onFocusLeave, ColumnHotkeyScope.EditColumnName); - useScopedHotkeys('esc', onFocusLeave, ColumnHotkeyScope.EditColumnName); - return ( - - ); -} diff --git a/front/src/modules/ui/board/components/__stories__/BoardColumnEditTitleMenu.story.tsx b/front/src/modules/ui/board/components/__stories__/BoardColumnEditTitleMenu.story.tsx new file mode 100644 index 000000000..14f66a037 --- /dev/null +++ b/front/src/modules/ui/board/components/__stories__/BoardColumnEditTitleMenu.story.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; + +import { BoardColumnEditTitleMenu } from '../BoardColumnEditTitleMenu'; + +const meta: Meta = { + title: 'UI/Board/BoardColumnMenu', + component: BoardColumnEditTitleMenu, +}; + +export default meta; +type Story = StoryObj; + +export const AllTags: Story = { + render: getRenderWrapperForComponent( + {}} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onTitleEdit={() => {}} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onColumnColorEdit={() => {}} + />, + ), +}; diff --git a/front/src/modules/ui/board/types/BoardColumnHotkeyScope.ts b/front/src/modules/ui/board/types/BoardColumnHotkeyScope.ts new file mode 100644 index 000000000..25663b4e3 --- /dev/null +++ b/front/src/modules/ui/board/types/BoardColumnHotkeyScope.ts @@ -0,0 +1,3 @@ +export enum BoardColumnHotkeyScope { + BoardColumn = 'board-column', +} diff --git a/front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx b/front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx index 11249c18e..d5d17ada5 100644 --- a/front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx +++ b/front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx @@ -50,7 +50,7 @@ const StyledDropdownButton = styled.div` } `; -const StyledDropdownMenuContainer = styled.ul` +export const StyledDropdownMenuContainer = styled.ul` position: absolute; right: 0; top: 14px; diff --git a/front/src/modules/ui/tag/components/Tag.tsx b/front/src/modules/ui/tag/components/Tag.tsx new file mode 100644 index 000000000..c924a502e --- /dev/null +++ b/front/src/modules/ui/tag/components/Tag.tsx @@ -0,0 +1,41 @@ +import styled from '@emotion/styled'; + +export const StyledTag = styled.h3<{ + colorHexCode?: string; + colorId?: string; +}>` + align-items: center; + background: ${({ colorId, theme }) => + colorId ? theme.tag.background[colorId] : null}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ colorHexCode, colorId, theme }) => + colorId ? theme.tag.text[colorId] : colorHexCode}; + display: flex; + flex-direction: row; + font-size: ${({ theme }) => theme.font.size.md}; + font-style: normal; + font-weight: ${({ theme }) => theme.font.weight.medium}; + gap: ${({ theme }) => theme.spacing(2)}; + margin: 0; + padding-bottom: ${({ theme }) => theme.spacing(1)}; + padding-left: ${({ theme }) => theme.spacing(2)}; + padding-right: ${({ theme }) => theme.spacing(2)}; + padding-top: ${({ theme }) => theme.spacing(1)}; +`; + +type OwnProps = { + color?: string; + text: string; + onClick?: () => void; +}; + +export function Tag({ color, text, onClick }: OwnProps) { + const colorHexCode = color?.charAt(0) === '#' ? color : undefined; + const colorId = color?.charAt(0) === '#' ? undefined : color; + + return ( + + {text} + + ); +} diff --git a/front/src/modules/ui/tag/components/__stories__/Tag.stories.tsx b/front/src/modules/ui/tag/components/__stories__/Tag.stories.tsx new file mode 100644 index 000000000..7d7f90d04 --- /dev/null +++ b/front/src/modules/ui/tag/components/__stories__/Tag.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; + +import { Tag } from '../Tag'; + +const meta: Meta = { + title: 'UI/Accessories/Tag', + component: Tag, +}; + +export default meta; +type Story = StoryObj; + +const TESTED_COLORS = [ + 'green', + 'turquoise', + 'sky', + 'blue', + 'purple', + 'pink', + 'red', + 'orange', + 'yellow', + 'gray', +]; + +export const AllTags: Story = { + render: getRenderWrapperForComponent( + <> + {TESTED_COLORS.map((color) => ( + + ))} + , + ), +}; diff --git a/front/src/modules/ui/themes/colors.ts b/front/src/modules/ui/themes/colors.ts index 4ee84e8a3..9a461e3ff 100644 --- a/front/src/modules/ui/themes/colors.ts +++ b/front/src/modules/ui/themes/colors.ts @@ -23,7 +23,7 @@ export const grayScale = { gray0: '#ffffff', }; -export const color = { +export const color: { [key: string]: string } = { yellow: '#ffd338', yellow80: '#2e2a1a', yellow70: '#453d1e', @@ -51,7 +51,7 @@ export const color = { turquoise30: '#9af0b0', turquoise20: '#c9fbd9', turquoise10: '#e8fde9', - sskyky: '#00e0ff', + sky: '#00e0ff', sky80: '#1a2d2e', sky70: '#1e3f40', sky60: '#224f50', diff --git a/front/src/modules/ui/themes/tag.ts b/front/src/modules/ui/themes/tag.ts new file mode 100644 index 000000000..b873d18b2 --- /dev/null +++ b/front/src/modules/ui/themes/tag.ts @@ -0,0 +1,55 @@ +import { color } from './colors'; + +export const tagLight: { [key: string]: { [key: string]: string } } = { + text: { + green: color.green60, + turquoise: color.turquoise60, + sky: color.sky60, + blue: color.blue60, + purple: color.purple60, + pink: color.pink60, + red: color.red60, + orange: color.orange60, + yellow: color.yellow60, + gray: color.gray60, + }, + background: { + green: color.green20, + turquoise: color.turquoise20, + sky: color.sky20, + blue: color.blue20, + purple: color.purple20, + pink: color.pink20, + red: color.red20, + orange: color.orange20, + yellow: color.yellow20, + gray: color.gray20, + }, +}; + +export const tagDark = { + text: { + green: color.green10, + turquoise: color.turquoise10, + sky: color.sky10, + blue: color.blue10, + purple: color.purple10, + pink: color.pink10, + red: color.red10, + orange: color.orange10, + yellow: color.yellow10, + gray: color.gray10, + }, + background: { + green: color.green60, + turquoise: color.turquoise60, + sky: color.sky60, + blue: color.blue60, + purple: color.purple60, + pink: color.pink60, + red: color.red60, + orange: color.orange60, + yellow: color.yellow60, + gray: color.gray60, + }, +}; diff --git a/front/src/modules/ui/themes/themes.ts b/front/src/modules/ui/themes/themes.ts index caaed3d70..d52ddd08d 100644 --- a/front/src/modules/ui/themes/themes.ts +++ b/front/src/modules/ui/themes/themes.ts @@ -7,6 +7,7 @@ import { boxShadowDark, boxShadowLight } from './boxShadow'; import { color, grayScale } from './colors'; import { fontDark, fontLight } from './font'; import { icon } from './icon'; +import { tagDark, tagLight } from './tag'; import { text } from './text'; const common = { @@ -47,6 +48,7 @@ export const lightTheme = { accent: accentLight, background: backgroundLight, border: borderLight, + tag: tagLight, boxShadow: boxShadowLight, font: fontLight, name: 'light', @@ -60,6 +62,7 @@ export const darkTheme: ThemeType = { accent: accentDark, background: backgroundDark, border: borderDark, + tag: tagDark, boxShadow: boxShadowDark, font: fontDark, name: 'dark', diff --git a/server/src/database/schema.prisma b/server/src/database/schema.prisma index 10c5a34b4..7ebb1d338 100644 --- a/server/src/database/schema.prisma +++ b/server/src/database/schema.prisma @@ -416,9 +416,12 @@ model PipelineStage { /// @Validator.IsOptional() id String @id @default(uuid()) /// @Validator.IsString() + /// @Validator.IsOptional() name String /// @Validator.IsString() + /// @Validator.IsOptional() type String + /// @Validator.IsOptional() /// @Validator.IsString() color String /// @Validator.IsNumber()