feature: add TaskDetails component
This commit is contained in:
parent
a877cd9414
commit
90f22d322e
@ -58,7 +58,8 @@
|
||||
"eject": "react-scripts eject",
|
||||
"storybook": "start-storybook -p 9009 -s public",
|
||||
"build-storybook": "build-storybook -s public",
|
||||
"generate": "graphql-codegen"
|
||||
"generate": "graphql-codegen",
|
||||
"lint": "eslint --ext js,ts,tsx src"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
|
1
web/src/citadel.d.ts
vendored
1
web/src/citadel.d.ts
vendored
@ -11,6 +11,7 @@ type Task = {
|
||||
name: string;
|
||||
position: number;
|
||||
labels: Label[];
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type TaskGroup = {
|
||||
|
33
web/src/shared/components/Modal/Modal.stories.tsx
Normal file
33
web/src/shared/components/Modal/Modal.stories.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import styled from 'styled-components';
|
||||
import Modal from './index';
|
||||
|
||||
export default {
|
||||
component: Modal,
|
||||
title: 'Modal',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Modal
|
||||
width={1040}
|
||||
onClose={action('on close')}
|
||||
renderContent={() => {
|
||||
return <h1>Hello!</h1>;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
33
web/src/shared/components/Modal/Styles.ts
Normal file
33
web/src/shared/components/Modal/Styles.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import styled from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const ScrollOverlay = styled.div`
|
||||
z-index: 1000000;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
export const ClickableOverlay = styled.div`
|
||||
min-height: 100%;
|
||||
background: rgba(9, 30, 66, 0.54);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 50px;
|
||||
`;
|
||||
|
||||
export const StyledModal = styled.div<{ width: number }>`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
max-width: ${props => props.width}px;
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
${mixin.boxShadowMedium}
|
||||
`;
|
36
web/src/shared/components/Modal/index.tsx
Normal file
36
web/src/shared/components/Modal/index.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React, { useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
||||
|
||||
import { ScrollOverlay, ClickableOverlay, StyledModal } from './Styles';
|
||||
|
||||
const $root: HTMLElement = document.getElementById('root')!;
|
||||
|
||||
type ModalProps = {
|
||||
width: number;
|
||||
onClose: () => void;
|
||||
renderContent: () => JSX.Element;
|
||||
};
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ width, onClose: tellParentToClose, renderContent }) => {
|
||||
const $modalRef = useRef<HTMLDivElement>(null);
|
||||
const $clickableOverlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOnOutsideClick($modalRef, true, tellParentToClose, $clickableOverlayRef);
|
||||
useOnEscapeKeyDown(true, tellParentToClose);
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<ScrollOverlay>
|
||||
<ClickableOverlay ref={$clickableOverlayRef}>
|
||||
<StyledModal width={width} ref={$modalRef}>
|
||||
{renderContent()}
|
||||
</StyledModal>
|
||||
</ClickableOverlay>
|
||||
</ScrollOverlay>,
|
||||
$root,
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
181
web/src/shared/components/TaskDetails/Styles.ts
Normal file
181
web/src/shared/components/TaskDetails/Styles.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import styled from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea/lib';
|
||||
|
||||
export const TaskHeader = styled.div`
|
||||
display: flex;
|
||||
-webkit-box-pack: justify;
|
||||
justify-content: space-between;
|
||||
padding: 21px 18px 0px;
|
||||
`;
|
||||
|
||||
export const TaskMeta = styled.div`
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export const TaskActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const TaskAction = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 14.5px;
|
||||
color: rgb(66, 82, 110);
|
||||
font-family: CircularStdBook;
|
||||
font-weight: normal;
|
||||
padding: 0px 9px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.1s ease 0s;
|
||||
background: rgb(255, 255, 255);
|
||||
`;
|
||||
|
||||
export const TaskDetailsWrapper = styled.div`
|
||||
display: flex;
|
||||
padding: 0px 30px 60px;
|
||||
`;
|
||||
|
||||
export const TaskDetailsContent = styled.div`
|
||||
width: 65%;
|
||||
padding-right: 50px;
|
||||
`;
|
||||
|
||||
export const TaskDetailsSidebar = styled.div`
|
||||
width: 35%;
|
||||
padding-top: 5px;
|
||||
`;
|
||||
|
||||
export const TaskDetailsTitleWrapper = styled.div`
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
margin: 18px 0px 0px -8px;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export const TaskDetailsTitle = styled(TextareaAutosize)`
|
||||
line-height: 1.28;
|
||||
resize: none;
|
||||
box-shadow: transparent 0px 0px 0px 1px;
|
||||
font-size: 24px;
|
||||
font-family: 'Droid Sans';
|
||||
font-weight: 700;
|
||||
padding: 7px 7px 8px;
|
||||
background: rgb(255, 255, 255);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
transition: background 0.1s ease 0s;
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
color: rgb(23, 43, 77);
|
||||
&:hover {
|
||||
background: rgb(235, 236, 240);
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: rgb(76, 154, 255) 0px 0px 0px 1px;
|
||||
background: rgb(255, 255, 255);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgb(76, 154, 255);
|
||||
border-image: initial;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TaskDetailsLabel = styled.div`
|
||||
padding: 20px 0px 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
export const TaskDetailsAddDetailsButton = styled.div`
|
||||
background-color: rgba(9, 30, 66, 0.04);
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
min-height: 56px;
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: rgba(9, 30, 66, 0.08);
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TaskDetailsEditorWrapper = styled.div`
|
||||
display: block;
|
||||
float: left;
|
||||
padding-bottom: 9px;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const TaskDetailsEditor = styled(TextareaAutosize)`
|
||||
width: 100%;
|
||||
min-height: 108px;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
border-color: rgba(9, 30, 66, 0.13);
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
background: #fff;
|
||||
border: none;
|
||||
box-shadow: inset 0 0 0 2px #0079bf;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TaskDetailsMarkdown = styled.div`
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const TaskDetailsControls = styled.div`
|
||||
clear: both;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
export const ConfirmSave = styled.div`
|
||||
background-color: #5aac44;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
float: left;
|
||||
margin: 0 4px 0 0;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export const CancelEdit = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
`;
|
@ -0,0 +1,47 @@
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import styled from 'styled-components';
|
||||
import Modal from 'shared/components/Modal';
|
||||
import TaskDetails from './';
|
||||
|
||||
export default {
|
||||
component: TaskDetails,
|
||||
title: 'TaskDetails',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
const [description, setDescription] = useState('');
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Modal
|
||||
width={1040}
|
||||
onClose={action('on close')}
|
||||
renderContent={() => {
|
||||
return (
|
||||
<TaskDetails
|
||||
task={{
|
||||
taskID: '1',
|
||||
taskGroupID: '1',
|
||||
name: 'Hello, world',
|
||||
position: 1,
|
||||
labels: [],
|
||||
description: description,
|
||||
}}
|
||||
onTaskDescriptionChange={(task, desc) => setDescription(desc)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
130
web/src/shared/components/TaskDetails/index.tsx
Normal file
130
web/src/shared/components/TaskDetails/index.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Bin, Cross } from 'shared/icons';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
|
||||
import {
|
||||
TaskActions,
|
||||
TaskAction,
|
||||
TaskMeta,
|
||||
TaskHeader,
|
||||
TaskDetailsContent,
|
||||
TaskDetailsWrapper,
|
||||
TaskDetailsSidebar,
|
||||
TaskDetailsTitleWrapper,
|
||||
TaskDetailsTitle,
|
||||
TaskDetailsLabel,
|
||||
TaskDetailsAddDetailsButton,
|
||||
TaskDetailsEditor,
|
||||
TaskDetailsEditorWrapper,
|
||||
TaskDetailsMarkdown,
|
||||
TaskDetailsControls,
|
||||
ConfirmSave,
|
||||
CancelEdit,
|
||||
} from './Styles';
|
||||
|
||||
type TaskContentProps = {
|
||||
onEditContent: () => void;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const TaskContent: React.FC<TaskContentProps> = ({ description, onEditContent }) => {
|
||||
return description === '' ? (
|
||||
<TaskDetailsAddDetailsButton onClick={onEditContent}>Add a more detailed description</TaskDetailsAddDetailsButton>
|
||||
) : (
|
||||
<TaskDetailsMarkdown onClick={onEditContent}>{description}</TaskDetailsMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
type DetailsEditorProps = {
|
||||
description: string;
|
||||
onTaskDescriptionChange: (newDescription: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const DetailsEditor: React.FC<DetailsEditorProps> = ({
|
||||
description: initialDescription,
|
||||
onTaskDescriptionChange,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const $editorWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const $editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const handleOutsideClick = () => {
|
||||
onTaskDescriptionChange(description);
|
||||
};
|
||||
useEffect(() => {
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
$editorRef.current.select();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useOnOutsideClick($editorWrapperRef, true, handleOutsideClick, null);
|
||||
return (
|
||||
<TaskDetailsEditorWrapper ref={$editorWrapperRef}>
|
||||
<TaskDetailsEditor
|
||||
ref={$editorRef}
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
<TaskDetailsControls>
|
||||
<ConfirmSave>Save</ConfirmSave>
|
||||
<CancelEdit onClick={onCancel}>
|
||||
<Cross size={16} />
|
||||
</CancelEdit>
|
||||
</TaskDetailsControls>
|
||||
</TaskDetailsEditorWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskDetailsProps = {
|
||||
task: Task;
|
||||
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
|
||||
};
|
||||
|
||||
const TaskDetails: React.FC<TaskDetailsProps> = ({ task, onTaskDescriptionChange }) => {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const handleClick = () => {
|
||||
setEditorOpen(!editorOpen);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<TaskHeader>
|
||||
<TaskMeta />
|
||||
<TaskActions>
|
||||
<TaskAction>
|
||||
<Bin size={20} />
|
||||
</TaskAction>
|
||||
<TaskAction>
|
||||
<Cross size={20} />
|
||||
</TaskAction>
|
||||
</TaskActions>
|
||||
</TaskHeader>
|
||||
<TaskDetailsWrapper>
|
||||
<TaskDetailsContent>
|
||||
<TaskDetailsTitleWrapper>
|
||||
<TaskDetailsTitle value="Hello darkness my old friend" />
|
||||
</TaskDetailsTitleWrapper>
|
||||
<TaskDetailsLabel>Description</TaskDetailsLabel>
|
||||
{editorOpen ? (
|
||||
<DetailsEditor
|
||||
description={task.description ?? ''}
|
||||
onTaskDescriptionChange={newDescription => {
|
||||
setEditorOpen(false);
|
||||
onTaskDescriptionChange(task, newDescription);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setEditorOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TaskContent description={task.description ?? ''} onEditContent={handleClick} />
|
||||
)}
|
||||
</TaskDetailsContent>
|
||||
<TaskDetailsSidebar />
|
||||
</TaskDetailsWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDetails;
|
22
web/src/shared/icons/Bin.tsx
Normal file
22
web/src/shared/icons/Bin.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size: number | string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const Bin = ({ size, color }: Props) => {
|
||||
return (
|
||||
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
|
||||
<path d="M2 5v10c0 0.55 0.45 1 1 1h9c0.55 0 1-0.45 1-1v-10h-11zM5 14h-1v-7h1v7zM7 14h-1v-7h1v7zM9 14h-1v-7h1v7zM11 14h-1v-7h1v7z" />
|
||||
<path d="M13.25 2h-3.25v-1.25c0-0.412-0.338-0.75-0.75-0.75h-3.5c-0.412 0-0.75 0.338-0.75 0.75v1.25h-3.25c-0.413 0-0.75 0.337-0.75 0.75v1.25h13v-1.25c0-0.413-0.338-0.75-0.75-0.75zM9 2h-3v-0.987h3v0.987z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
Bin.defaultProps = {
|
||||
size: 16,
|
||||
color: '#000',
|
||||
};
|
||||
|
||||
export default Bin;
|
@ -1,5 +1,6 @@
|
||||
import Cross from './Cross';
|
||||
import Bell from './Bell';
|
||||
import Bin from './Bin';
|
||||
import Pencil from './Pencil';
|
||||
import Checkmark from './Checkmark';
|
||||
import User from './User';
|
||||
@ -11,4 +12,4 @@ import Stack from './Stack';
|
||||
import Question from './Question';
|
||||
import Exit from './Exit';
|
||||
|
||||
export { Cross, Bell, Exit, Pencil, Stack, Question, Home, Citadel, Checkmark, User, Users, Lock };
|
||||
export { Cross, Bell, Bin, Exit, Pencil, Stack, Question, Home, Citadel, Checkmark, User, Users, Lock };
|
||||
|
Loading…
Reference in New Issue
Block a user