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
This commit is contained in:
Emilien Chauvet 2023-07-20 16:45:43 -07:00 committed by GitHub
parent a2087da624
commit 9c230f448e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 415 additions and 103 deletions

View File

@ -2185,11 +2185,11 @@ export type DeleteManyPipelineProgressMutation = { __typename?: 'Mutation', dele
export type UpdatePipelineStageMutationVariables = Exact<{
id?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
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<PersonWhereInput>;
@ -3957,10 +3957,11 @@ export type DeleteManyPipelineProgressMutationHookResult = ReturnType<typeof use
export type DeleteManyPipelineProgressMutationResult = Apollo.MutationResult<DeleteManyPipelineProgressMutation>;
export type DeleteManyPipelineProgressMutationOptions = Apollo.BaseMutationOptions<DeleteManyPipelineProgressMutation, DeleteManyPipelineProgressMutationVariables>;
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<UpdatePipeli
* const [updatePipelineStageMutation, { data, loading, error }] = useUpdatePipelineStageMutation({
* variables: {
* id: // value for 'id'
* name: // value for 'name'
* data: // value for 'data'
* },
* });
*/

View File

@ -71,7 +71,17 @@ export function EntityBoardColumn({
updatePipelineStage({
variables: {
id: pipelineStageId,
name: value,
data: { name: value },
},
refetchQueries: [getOperationName(GET_PIPELINES) || ''],
});
}
function handleEditColumnColor(value: string) {
updatePipelineStage({
variables: {
id: pipelineStageId,
data: { color: value },
},
refetchQueries: [getOperationName(GET_PIPELINES) || ''],
});
@ -81,9 +91,10 @@ export function EntityBoardColumn({
<Droppable droppableId={column.pipelineStageId}>
{(droppableProvided) => (
<BoardColumn
onColumnColorEdit={handleEditColumnColor}
onTitleEdit={handleEditColumnTitle}
title={column.title}
colorCode={column.colorCode}
color={column.colorCode}
pipelineStageId={column.pipelineStageId}
totalAmount={boardColumnTotal}
isFirstColumn={column.index === 0}

View File

@ -9,10 +9,11 @@ export const DELETE_PIPELINE_PROGRESS = gql`
`;
export const UPDATE_PIPELINE_STAGE = 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
}
}
`;

View File

@ -1,9 +1,14 @@
import React, { ChangeEvent } from 'react';
import React from 'react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { debounce } from '~/utils/debounce';
import { usePreviousHotkeyScope } from '@/ui/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { Tag } from '@/ui/tag/components/Tag';
import { EditColumnTitleInput } from './EditColumnTitleInput';
import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope';
import { BoardColumnMenu } from './BoardColumnMenu';
export const StyledColumn = styled.div<{ isFirstColumn: boolean }>`
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<HTMLInputElement>) => {
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 (
<StyledColumn isFirstColumn={isFirstColumn}>
<StyledHeader onClick={() => setIsEditing(true)}>
<StyledColumnTitle color={colorCode}>
{isEditing ? (
<EditColumnTitleInput
color={colorCode}
onFocusLeave={() => setIsEditing(false)}
value={internalValue}
onChange={handleChange}
/>
) : (
<div>{title}</div>
)}
</StyledColumnTitle>
<StyledHeader>
<Tag onClick={handleTitleClick} color={color} text={title} />
{!!totalAmount && <StyledAmount>${totalAmount}</StyledAmount>}
</StyledHeader>
{isBoardColumnMenuOpen && (
<BoardColumnMenu
onClose={() => setIsBoardColumnMenuOpen(false)}
onTitleEdit={onTitleEdit}
onColumnColorEdit={onColumnColorEdit}
title={title}
color={color}
/>
)}
{children}
</StyledColumn>
);

View File

@ -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<HTMLInputElement>) => {
setInternalValue(event.target.value);
debouncedOnUpdate(event.target.value);
};
return (
<DropdownMenuItemsContainer>
<StyledEditTitleContainer>
<StyledEditModeInput
value={internalValue}
onChange={handleChange}
autoFocus
/>
</StyledEditTitleContainer>
<DropdownMenuSeparator />
{COLOR_OPTIONS.map((colorOption) => (
<DropdownMenuSelectableItem
key={colorOption.name}
onClick={() => {
onColumnColorEdit(colorOption.id);
onClose();
}}
selected={colorOption.id === color}
>
<StyledColorSample colorName={colorOption.id} />
{colorOption.name}
</DropdownMenuSelectableItem>
))}
</DropdownMenuItemsContainer>
);
}

View File

@ -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 (
<StyledMenuContainer ref={boardColumnMenuRef}>
<DropdownMenu>
{openMenu === 'actions' && (
<DropdownMenuItemsContainer>
<DropdownMenuSelectableItem onClick={() => setOpenMenu('title')}>
<DropdownButton.StyledIcon>
<IconPencil size={icon.size.md} stroke={icon.stroke.sm} />
</DropdownButton.StyledIcon>
Rename
</DropdownMenuSelectableItem>
</DropdownMenuItemsContainer>
)}
{openMenu === 'title' && (
<BoardColumnEditTitleMenu
color={color}
onClose={onClose}
onTitleEdit={onTitleEdit}
onColumnColorEdit={onColumnColorEdit}
title={title}
/>
)}
</DropdownMenu>
</StyledMenuContainer>
);
}

View File

@ -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<HTMLInputElement>) => void;
onFocusLeave: () => void;
}) {
const inputRef = React.useRef<HTMLInputElement>(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 (
<StyledEditTitleInput
ref={inputRef}
placeholder={'Enter column name'}
color={color}
autoFocus
value={value}
onChange={onChange}
/>
);
}

View File

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { BoardColumnEditTitleMenu } from '../BoardColumnEditTitleMenu';
const meta: Meta<typeof BoardColumnEditTitleMenu> = {
title: 'UI/Board/BoardColumnMenu',
component: BoardColumnEditTitleMenu,
};
export default meta;
type Story = StoryObj<typeof BoardColumnEditTitleMenu>;
export const AllTags: Story = {
render: getRenderWrapperForComponent(
<BoardColumnEditTitleMenu
color="green"
title={'Column title'}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onClose={() => {}}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onTitleEdit={() => {}}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onColumnColorEdit={() => {}}
/>,
),
};

View File

@ -0,0 +1,3 @@
export enum BoardColumnHotkeyScope {
BoardColumn = 'board-column',
}

View File

@ -50,7 +50,7 @@ const StyledDropdownButton = styled.div<StyledDropdownButtonProps>`
}
`;
const StyledDropdownMenuContainer = styled.ul`
export const StyledDropdownMenuContainer = styled.ul`
position: absolute;
right: 0;
top: 14px;

View File

@ -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 (
<StyledTag colorHexCode={colorHexCode} colorId={colorId} onClick={onClick}>
{text}
</StyledTag>
);
}

View File

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { Tag } from '../Tag';
const meta: Meta<typeof Tag> = {
title: 'UI/Accessories/Tag',
component: Tag,
};
export default meta;
type Story = StoryObj<typeof Tag>;
const TESTED_COLORS = [
'green',
'turquoise',
'sky',
'blue',
'purple',
'pink',
'red',
'orange',
'yellow',
'gray',
];
export const AllTags: Story = {
render: getRenderWrapperForComponent(
<>
{TESTED_COLORS.map((color) => (
<Tag text="Urgent" color={color} />
))}
</>,
),
};

View File

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

View File

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

View File

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

View File

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