arch: move web folder into api & move api to top level
This commit is contained in:
19
frontend/src/shared/components/AddList/AddList.stories.tsx
Normal file
19
frontend/src/shared/components/AddList/AddList.stories.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import AddList from '.';
|
||||
|
||||
export default {
|
||||
component: AddList,
|
||||
title: 'AddList',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#262c49', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return <AddList onSave={action('on save')} />;
|
||||
};
|
||||
|
113
frontend/src/shared/components/AddList/Styles.ts
Normal file
113
frontend/src/shared/components/AddList/Styles.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea/lib';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
export const Container = styled.div`
|
||||
width: 272px;
|
||||
margin: 0 4px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div<{ editorOpen: boolean }>`
|
||||
display: inline-block;
|
||||
background-color: hsla(0, 0%, 100%, 0.24);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
height: auto;
|
||||
min-height: 32px;
|
||||
padding: 4px;
|
||||
transition: background 85ms ease-in, opacity 40ms ease-in, border-color 85ms ease-in;
|
||||
width: 272px;
|
||||
margin: 0 4px;
|
||||
margin-right: 8px;
|
||||
|
||||
${props =>
|
||||
!props.editorOpen &&
|
||||
css`
|
||||
&:hover {
|
||||
background-color: hsla(0, 0%, 100%, 0.32);
|
||||
}
|
||||
`}
|
||||
|
||||
${props =>
|
||||
props.editorOpen &&
|
||||
css`
|
||||
background-color: #10163a;
|
||||
border-radius: 3px;
|
||||
height: auto;
|
||||
min-height: 32px;
|
||||
padding: 8px;
|
||||
transition: background 85ms ease-in, opacity 40ms ease-in, border-color 85ms ease-in;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const AddListButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
export const Placeholder = styled.span`
|
||||
color: #c2c6dc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
transition: color 85ms ease-in;
|
||||
`;
|
||||
|
||||
export const AddIconWrapper = styled.div`
|
||||
color: #fff;
|
||||
margin-right: 6px;
|
||||
`;
|
||||
|
||||
export const ListNameEditorWrapper = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
export const ListNameEditor = styled(TextareaAutosize)`
|
||||
background-color: ${props => mixin.lighten('#262c49', 0.05)};
|
||||
border: none;
|
||||
box-shadow: inset 0 0 0 2px #0079bf;
|
||||
transition: margin 85ms ease-in, background 85ms ease-in;
|
||||
line-height: 20px;
|
||||
padding: 8px 12px;
|
||||
|
||||
font-family: 'Droid Sans';
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
resize: none;
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
margin-bottom: 4px;
|
||||
max-height: 162px;
|
||||
min-height: 54px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
color: #c2c6dc;
|
||||
l &:focus {
|
||||
background-color: ${props => mixin.lighten('#262c49', 0.05)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ListAddControls = styled.div`
|
||||
height: 32px;
|
||||
transition: margin 85ms ease-in, height 85ms ease-in;
|
||||
overflow: hidden;
|
||||
margin: 4px 0 0;
|
||||
`;
|
||||
|
||||
export const CancelAdd = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
`;
|
111
frontend/src/shared/components/AddList/index.tsx
Normal file
111
frontend/src/shared/components/AddList/index.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Plus, Cross } from 'shared/icons';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
import {
|
||||
Container,
|
||||
Wrapper,
|
||||
Placeholder,
|
||||
AddIconWrapper,
|
||||
AddListButton,
|
||||
ListNameEditor,
|
||||
ListAddControls,
|
||||
CancelAdd,
|
||||
ListNameEditorWrapper,
|
||||
} from './Styles';
|
||||
|
||||
type NameEditorProps = {
|
||||
onSave: (listName: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
|
||||
const $editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [listName, setListName] = useState('');
|
||||
useEffect(() => {
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
}
|
||||
});
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSave(listName);
|
||||
setListName('');
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<ListNameEditorWrapper>
|
||||
<ListNameEditor
|
||||
ref={$editorRef}
|
||||
onKeyDown={onKeyDown}
|
||||
value={listName}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)}
|
||||
placeholder="Enter a title for this list..."
|
||||
/>
|
||||
</ListNameEditorWrapper>
|
||||
<ListAddControls>
|
||||
<AddListButton
|
||||
variant="relief"
|
||||
onClick={() => {
|
||||
onSave(listName);
|
||||
setListName('');
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</AddListButton>
|
||||
<CancelAdd onClick={() => onCancel()}>
|
||||
<Cross width={16} height={16} />
|
||||
</CancelAdd>
|
||||
</ListAddControls>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type AddListProps = {
|
||||
onSave: (listName: string) => void;
|
||||
};
|
||||
|
||||
const AddList: React.FC<AddListProps> = ({ onSave }) => {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const $wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const onOutsideClick = () => {
|
||||
setEditorOpen(false);
|
||||
};
|
||||
useOnOutsideClick($wrapperRef, editorOpen, onOutsideClick, null);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Wrapper
|
||||
ref={$wrapperRef}
|
||||
editorOpen={editorOpen}
|
||||
onClick={() => {
|
||||
if (!editorOpen) {
|
||||
setEditorOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editorOpen ? (
|
||||
<NameEditor onCancel={() => setEditorOpen(false)} onSave={onSave} />
|
||||
) : (
|
||||
<Placeholder>
|
||||
<AddIconWrapper>
|
||||
<Plus size={12} color="#c2c6dc" />
|
||||
</AddIconWrapper>
|
||||
Add another list
|
||||
</Placeholder>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddList;
|
49
frontend/src/shared/components/Admin/Admin.stories.tsx
Normal file
49
frontend/src/shared/components/Admin/Admin.stories.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React, { useRef } from 'react';
|
||||
import Admin from '.';
|
||||
import { theme } from 'App/ThemeStyles';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
export default {
|
||||
component: Admin,
|
||||
title: 'Admin',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<ThemeProvider theme={theme}>
|
||||
<Admin
|
||||
onInviteUser={action('invite user')}
|
||||
initialTab={1}
|
||||
onDeleteUser={action('delete user')}
|
||||
users={[
|
||||
{
|
||||
id: '1',
|
||||
username: 'jordanthedev',
|
||||
email: 'jordan@jordanthedev.com',
|
||||
role: { code: 'admin', name: 'Admin' },
|
||||
fullName: 'Jordan Knott',
|
||||
profileIcon: {
|
||||
bgColor: '#fff',
|
||||
initials: 'JK',
|
||||
url: null,
|
||||
},
|
||||
},
|
||||
]}
|
||||
onAddUser={action('add user')}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
};
|
373
frontend/src/shared/components/Admin/index.tsx
Normal file
373
frontend/src/shared/components/Admin/index.tsx
Normal file
@ -0,0 +1,373 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { User, Plus, Lock, Pencil, Trash } from 'shared/icons';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
|
||||
import 'ag-grid-community/dist/styles/ag-grid.css';
|
||||
import 'ag-grid-community/dist/styles/ag-theme-material.css';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
const NewUserButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
const InviteUserButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const MemberActions = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const GridTable = styled.div`
|
||||
height: 620px;
|
||||
`;
|
||||
|
||||
const RootWrapper = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Root = styled.div`
|
||||
.ag-theme-material {
|
||||
--ag-foreground-color: #c2c6dc;
|
||||
--ag-secondary-foreground-color: #c2c6dc;
|
||||
--ag-background-color: transparent;
|
||||
--ag-header-background-color: transparent;
|
||||
--ag-header-foreground-color: #c2c6dc;
|
||||
--ag-border-color: #414561;
|
||||
|
||||
--ag-row-hover-color: #262c49;
|
||||
--ag-header-cell-hover-background-color: #262c49;
|
||||
--ag-checkbox-unchecked-color: #c2c6dc;
|
||||
--ag-checkbox-indeterminate-color: rgba(115, 103, 240);
|
||||
--ag-selected-row-background-color: #262c49;
|
||||
--ag-material-primary-color: rgba(115, 103, 240);
|
||||
--ag-material-accent-color: rgba(115, 103, 240);
|
||||
}
|
||||
.ag-theme-material ::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.ag-theme-material ::-webkit-scrollbar-track {
|
||||
background: #262c49;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.ag-theme-material ::-webkit-scrollbar-thumb {
|
||||
background: #7367f0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.ag-header-cell-text {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
border-bottom: 1px solid #e2e2e2;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-bottom-color: #414561;
|
||||
color: #fff;
|
||||
|
||||
height: 112px;
|
||||
min-height: 112px;
|
||||
`;
|
||||
|
||||
const EditUserIcon = styled(Pencil)``;
|
||||
|
||||
const LockUserIcon = styled(Lock)``;
|
||||
|
||||
const DeleteUserIcon = styled(Trash)``;
|
||||
|
||||
type ActionButtonProps = {
|
||||
onClick: ($target: React.RefObject<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const ActionButtonWrapper = styled.div`
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
`;
|
||||
|
||||
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
|
||||
const $wrapper = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}>
|
||||
{children}
|
||||
</ActionButtonWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ActionButtons = (params: any) => {
|
||||
return (
|
||||
<>
|
||||
<ActionButton onClick={() => {}}>
|
||||
<EditUserIcon width={16} height={16} />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => {}}>
|
||||
<LockUserIcon width={16} height={16} />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
|
||||
<DeleteUserIcon width={16} height={16} />
|
||||
</ActionButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ListTableProps = {
|
||||
users: Array<User>;
|
||||
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
|
||||
};
|
||||
|
||||
const ListTable: React.FC<ListTableProps> = ({ users, onDeleteUser }) => {
|
||||
const data = {
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
minWidth: 55,
|
||||
width: 55,
|
||||
headerCheckboxSelection: true,
|
||||
checkboxSelection: true,
|
||||
},
|
||||
{ minWidth: 210, headerName: 'Username', editable: true, field: 'username' },
|
||||
{ minWidth: 225, headerName: 'Email', field: 'email' },
|
||||
{ minWidth: 200, headerName: 'Name', editable: true, field: 'fullName' },
|
||||
{ minWidth: 200, headerName: 'Role', editable: true, field: 'roleName' },
|
||||
{
|
||||
minWidth: 200,
|
||||
headerName: 'Actions',
|
||||
field: 'id',
|
||||
cellRenderer: 'actionButtons',
|
||||
cellRendererParams: {
|
||||
onDeleteUser: (target: any, userID: any) => {
|
||||
onDeleteUser(target, userID);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
frameworkComponents: {
|
||||
actionButtons: ActionButtons,
|
||||
},
|
||||
};
|
||||
return (
|
||||
<Root>
|
||||
<div className="ag-theme-material" style={{ height: '296px', width: '100%' }}>
|
||||
<AgGridReact
|
||||
rowSelection="multiple"
|
||||
defaultColDef={data.defaultColDef}
|
||||
columnDefs={data.columnDefs}
|
||||
rowData={users.map(u => ({ ...u, roleName: u.role.name }))}
|
||||
frameworkComponents={data.frameworkComponents}
|
||||
onFirstDataRendered={params => {
|
||||
params.api.sizeColumnsToFit();
|
||||
}}
|
||||
onGridSizeChanged={params => {
|
||||
params.api.sizeColumnsToFit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background: #eff2f7;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 2.2rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNav = styled.div`
|
||||
float: left;
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNavContent = styled.ul`
|
||||
display: block;
|
||||
width: auto;
|
||||
border-bottom: 0 !important;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const TabNavItem = styled.li`
|
||||
padding: 0.35rem 0.3rem;
|
||||
height: 48px;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
const TabNavItemButton = styled.button<{ active: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')};
|
||||
&:hover {
|
||||
color: rgba(115, 103, 240);
|
||||
}
|
||||
&:hover svg {
|
||||
fill: rgba(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
const TabNavItemSpan = styled.span`
|
||||
text-align: left;
|
||||
padding-left: 9px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const TabNavLine = styled.span<{ top: number }>`
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: 2px;
|
||||
height: 48px;
|
||||
transform: scaleX(1);
|
||||
top: ${props => props.top}px;
|
||||
|
||||
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240));
|
||||
box-shadow: 0 0 8px 0 rgba(115, 103, 240);
|
||||
display: block;
|
||||
position: absolute;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
const TabContentWrapper = styled.div`
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const TabContent = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 0;
|
||||
padding: 1.5rem;
|
||||
background-color: #10163a;
|
||||
margin-left: 1rem !important;
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
const items = [
|
||||
{ name: 'Insights' },
|
||||
{ name: 'Members' },
|
||||
{ name: 'Teams' },
|
||||
{ name: 'Security' },
|
||||
{ name: 'Settings' },
|
||||
];
|
||||
|
||||
type NavItemProps = {
|
||||
active: boolean;
|
||||
name: string;
|
||||
tab: number;
|
||||
onClick: (tab: number, top: number) => void;
|
||||
};
|
||||
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
||||
const $item = useRef<HTMLLIElement>(null);
|
||||
return (
|
||||
<TabNavItem
|
||||
key={name}
|
||||
ref={$item}
|
||||
onClick={() => {
|
||||
if ($item && $item.current) {
|
||||
const pos = $item.current.getBoundingClientRect();
|
||||
onClick(tab, pos.top);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabNavItemButton active={active}>
|
||||
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
||||
</TabNavItemButton>
|
||||
</TabNavItem>
|
||||
);
|
||||
};
|
||||
|
||||
type AdminProps = {
|
||||
initialTab: number;
|
||||
onAddUser: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
|
||||
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
|
||||
users: Array<User>;
|
||||
};
|
||||
|
||||
const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onDeleteUser, onInviteUser, users }) => {
|
||||
const [currentTop, setTop] = useState(initialTab * 48);
|
||||
const [currentTab, setTab] = useState(initialTab);
|
||||
const $tabNav = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Container>
|
||||
<TabNav ref={$tabNav}>
|
||||
<TabNavContent>
|
||||
{items.map((item, idx) => (
|
||||
<NavItem
|
||||
onClick={(tab, top) => {
|
||||
if ($tabNav && $tabNav.current) {
|
||||
const pos = $tabNav.current.getBoundingClientRect();
|
||||
setTab(tab);
|
||||
setTop(top - pos.top);
|
||||
}
|
||||
}}
|
||||
name={item.name}
|
||||
tab={idx}
|
||||
active={idx === currentTab}
|
||||
/>
|
||||
))}
|
||||
<TabNavLine top={currentTop} />
|
||||
</TabNavContent>
|
||||
</TabNav>
|
||||
<TabContentWrapper>
|
||||
<TabContent>
|
||||
<MemberActions>
|
||||
<NewUserButton variant="outline" onClick={onAddUser}>
|
||||
<Plus color="rgba(115, 103, 240)" size={10} />
|
||||
<span style={{ paddingLeft: '5px' }}>Create member</span>
|
||||
</NewUserButton>
|
||||
<InviteUserButton variant="outline" onClick={onInviteUser}>
|
||||
<Plus color="rgba(115, 103, 240)" size={10} />
|
||||
<span style={{ paddingLeft: '5px' }}>Invite member</span>
|
||||
</InviteUserButton>
|
||||
</MemberActions>
|
||||
<ListTable onDeleteUser={onDeleteUser} users={users} />
|
||||
</TabContent>
|
||||
</TabContentWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
138
frontend/src/shared/components/Button/Button.stories.tsx
Normal file
138
frontend/src/shared/components/Button/Button.stories.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import { theme } from 'App/ThemeStyles';
|
||||
import styled, { ThemeProvider } from 'styled-components';
|
||||
import Button from '.';
|
||||
|
||||
export default {
|
||||
component: Button,
|
||||
title: 'Button',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
margin: 25px;
|
||||
width: 100%;
|
||||
& > button {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<BaseStyles />
|
||||
<NormalizeStyles />
|
||||
<ThemeProvider theme={theme}>
|
||||
<ButtonRow>
|
||||
<Button>Primary</Button>
|
||||
<Button color="success">Success</Button>
|
||||
<Button color="danger">Danger</Button>
|
||||
<Button color="warning">Warning</Button>
|
||||
<Button color="dark">Dark</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
</ButtonRow>
|
||||
<ButtonRow>
|
||||
<Button variant="outline">Primary</Button>
|
||||
<Button variant="outline" color="success">
|
||||
Success
|
||||
</Button>
|
||||
<Button variant="outline" color="danger">
|
||||
Danger
|
||||
</Button>
|
||||
<Button variant="outline" color="warning">
|
||||
Warning
|
||||
</Button>
|
||||
<Button variant="outline" color="dark">
|
||||
Dark
|
||||
</Button>
|
||||
<Button variant="outline" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
<ButtonRow>
|
||||
<Button variant="flat">Primary</Button>
|
||||
<Button variant="flat" color="success">
|
||||
Success
|
||||
</Button>
|
||||
<Button variant="flat" color="danger">
|
||||
Danger
|
||||
</Button>
|
||||
<Button variant="flat" color="warning">
|
||||
Warning
|
||||
</Button>
|
||||
<Button variant="flat" color="dark">
|
||||
Dark
|
||||
</Button>
|
||||
<Button variant="flat" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
<ButtonRow>
|
||||
<Button variant="lineDown">Primary</Button>
|
||||
<Button variant="lineDown" color="success">
|
||||
Success
|
||||
</Button>
|
||||
<Button variant="lineDown" color="danger">
|
||||
Danger
|
||||
</Button>
|
||||
<Button variant="lineDown" color="warning">
|
||||
Warning
|
||||
</Button>
|
||||
<Button variant="lineDown" color="dark">
|
||||
Dark
|
||||
</Button>
|
||||
<Button variant="lineDown" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
<ButtonRow>
|
||||
<Button variant="gradient">Primary</Button>
|
||||
<Button variant="gradient" color="success">
|
||||
Success
|
||||
</Button>
|
||||
<Button variant="gradient" color="danger">
|
||||
Danger
|
||||
</Button>
|
||||
<Button variant="gradient" color="warning">
|
||||
Warning
|
||||
</Button>
|
||||
<Button variant="gradient" color="dark">
|
||||
Dark
|
||||
</Button>
|
||||
<Button variant="gradient" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
<ButtonRow>
|
||||
<Button variant="relief">Primary</Button>
|
||||
<Button variant="relief" color="success">
|
||||
Success
|
||||
</Button>
|
||||
<Button variant="relief" color="danger">
|
||||
Danger
|
||||
</Button>
|
||||
<Button variant="relief" color="warning">
|
||||
Warning
|
||||
</Button>
|
||||
<Button variant="relief" color="dark">
|
||||
Dark
|
||||
</Button>
|
||||
<Button variant="relief" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
};
|
197
frontend/src/shared/components/Button/index.tsx
Normal file
197
frontend/src/shared/components/Button/index.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import React, { useRef } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
|
||||
const Text = styled.span<{ fontSize: string }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
font-size: ${props => props.fontSize};
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
`;
|
||||
|
||||
const Base = styled.button<{ color: string; disabled: boolean }>`
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: ${props => props.theme.borderRadius.alternate};
|
||||
|
||||
${props =>
|
||||
props.disabled &&
|
||||
css`
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Filled = styled(Base)`
|
||||
background: rgba(${props => props.theme.colors[props.color]});
|
||||
&:hover {
|
||||
box-shadow: 0 8px 25px -8px rgba(${props => props.theme.colors[props.color]});
|
||||
}
|
||||
`;
|
||||
const Outline = styled(Base)`
|
||||
border: 1px solid rgba(${props => props.theme.colors[props.color]});
|
||||
background: transparent;
|
||||
& ${Text} {
|
||||
color: rgba(${props => props.theme.colors[props.color]});
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(${props => props.theme.colors[props.color]}, 0.08);
|
||||
}
|
||||
`;
|
||||
|
||||
const Flat = styled(Base)`
|
||||
background: transparent;
|
||||
&:hover {
|
||||
background: rgba(${props => props.theme.colors[props.color]}, 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
const LineX = styled.span<{ color: string }>`
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
width: 0;
|
||||
top: auto;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
background: rgba(${props => props.theme.colors[props.color]}, 1);
|
||||
`;
|
||||
|
||||
const LineDown = styled(Base)`
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-bottom-width: 2px;
|
||||
border-color: rgba(${props => props.theme.colors[props.color]}, 0.2);
|
||||
|
||||
&:hover ${LineX} {
|
||||
width: 100%;
|
||||
}
|
||||
&:hover ${Text} {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const Gradient = styled(Base)`
|
||||
background: linear-gradient(
|
||||
30deg,
|
||||
rgba(${props => props.theme.colors[props.color]}, 1),
|
||||
rgba(${props => props.theme.colors[props.color]}, 0.5)
|
||||
);
|
||||
text-shadow: 1px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const Relief = styled(Base)`
|
||||
background: rgba(${props => props.theme.colors[props.color]}, 1);
|
||||
-webkit-box-shadow: 0 -3px 0 0 rgba(0, 0, 0, 0.2) inset;
|
||||
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:active {
|
||||
transform: translateY(3px);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
type ButtonProps = {
|
||||
fontSize?: string;
|
||||
variant?: 'filled' | 'outline' | 'flat' | 'lineDown' | 'gradient' | 'relief';
|
||||
color?: 'primary' | 'danger' | 'success' | 'warning' | 'dark';
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit';
|
||||
className?: string;
|
||||
onClick?: ($target: React.RefObject<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
disabled = false,
|
||||
fontSize = '14px',
|
||||
color = 'primary',
|
||||
variant = 'filled',
|
||||
type = 'button',
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
const $button = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick($button);
|
||||
}
|
||||
};
|
||||
switch (variant) {
|
||||
case 'filled':
|
||||
return (
|
||||
<Filled ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
|
||||
<Text fontSize={fontSize}>{children}</Text>
|
||||
</Filled>
|
||||
);
|
||||
case 'outline':
|
||||
return (
|
||||
<Outline
|
||||
ref={$button}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
color={color}
|
||||
>
|
||||
<Text fontSize={fontSize}>{children}</Text>
|
||||
</Outline>
|
||||
);
|
||||
case 'flat':
|
||||
return (
|
||||
<Flat ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
|
||||
<Text fontSize={fontSize}>{children}</Text>
|
||||
</Flat>
|
||||
);
|
||||
case 'lineDown':
|
||||
return (
|
||||
<LineDown
|
||||
ref={$button}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
color={color}
|
||||
>
|
||||
<Text fontSize={fontSize}>{children}</Text>
|
||||
<LineX color={color} />
|
||||
</LineDown>
|
||||
);
|
||||
case 'gradient':
|
||||
return (
|
||||
<Gradient
|
||||
ref={$button}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
color={color}
|
||||
>
|
||||
<Text fontSize={fontSize}>{children}</Text>
|
||||
</Gradient>
|
||||
);
|
||||
case 'relief':
|
||||
return (
|
||||
<Relief ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
|
||||
<Text fontSize={fontSize}>{children}</Text>
|
||||
</Relief>
|
||||
);
|
||||
default:
|
||||
throw new Error('not a valid variant');
|
||||
}
|
||||
};
|
||||
|
||||
export default Button;
|
174
frontend/src/shared/components/Card/Card.stories.tsx
Normal file
174
frontend/src/shared/components/Card/Card.stories.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import LabelColors from 'shared/constants/labelColors';
|
||||
import Card from '.';
|
||||
|
||||
export default {
|
||||
component: Card,
|
||||
title: 'Card',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const labelData: Array<ProjectLabel> = [
|
||||
{
|
||||
id: 'development',
|
||||
name: 'Development',
|
||||
createdDate: new Date().toString(),
|
||||
labelColor: {
|
||||
id: '1',
|
||||
colorHex: LabelColors.BLUE,
|
||||
name: 'blue',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const Default = () => {
|
||||
const $ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description=""
|
||||
ref={$ref}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Labels = () => {
|
||||
const $ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description=""
|
||||
ref={$ref}
|
||||
title="Hello, world"
|
||||
labels={labelData}
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Badges = () => {
|
||||
const $ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description="hello!"
|
||||
ref={$ref}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
watched
|
||||
checklists={{ complete: 1, total: 4 }}
|
||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const PastDue = () => {
|
||||
const $ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description="hello!"
|
||||
ref={$ref}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
watched
|
||||
checklists={{ complete: 1, total: 4 }}
|
||||
dueDate={{ isPastDue: true, formattedDate: 'Oct 26, 2020' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Everything = () => {
|
||||
const $ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description="hello!"
|
||||
ref={$ref}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
watched
|
||||
members={[
|
||||
{
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
profileIcon: {
|
||||
bgColor: '#0079bf',
|
||||
initials: 'JK',
|
||||
url: null,
|
||||
},
|
||||
},
|
||||
]}
|
||||
labels={labelData}
|
||||
checklists={{ complete: 1, total: 4 }}
|
||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Members = () => {
|
||||
const $ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Card
|
||||
description={null}
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
ref={$ref}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
members={[
|
||||
{
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
profileIcon: {
|
||||
bgColor: '#0079bf',
|
||||
initials: 'JK',
|
||||
url: null,
|
||||
},
|
||||
},
|
||||
]}
|
||||
labels={[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Editable = () => {
|
||||
const $ref = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description="hello!"
|
||||
ref={$ref}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
watched
|
||||
labels={labelData}
|
||||
checklists={{ complete: 1, total: 4 }}
|
||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||
editable
|
||||
onEditCard={action('edit card')}
|
||||
/>
|
||||
);
|
||||
};
|
171
frontend/src/shared/components/Card/Styles.ts
Normal file
171
frontend/src/shared/components/Card/Styles.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { CheckCircle } from 'shared/icons';
|
||||
import { RefObject } from 'react';
|
||||
|
||||
export const ClockIcon = styled(FontAwesomeIcon)``;
|
||||
|
||||
export const EditorTextarea = styled(TextareaAutosize)`
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
resize: none;
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
margin-bottom: 4px;
|
||||
max-height: 162px;
|
||||
min-height: 54px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ListCardBadges = styled.div`
|
||||
float: left;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
margin-left: -2px;
|
||||
`;
|
||||
|
||||
export const ListCardBadge = styled.div`
|
||||
color: #5e6c84;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 6px 4px 0;
|
||||
font-size: 12px;
|
||||
max-width: 100%;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 2px;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
`;
|
||||
|
||||
export const DescriptionBadge = styled(ListCardBadge)`
|
||||
padding-right: 6px;
|
||||
`;
|
||||
|
||||
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
|
||||
font-size: 12px;
|
||||
${props =>
|
||||
props.isPastDue &&
|
||||
css`
|
||||
padding-left: 4px;
|
||||
background-color: #ec9488;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const ListCardBadgeText = styled.span`
|
||||
font-size: 12px;
|
||||
padding: 0 4px 0 6px;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
|
||||
max-width: 256px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 3px;
|
||||
${mixin.boxShadowCard}
|
||||
cursor: pointer !important;
|
||||
position: relative;
|
||||
|
||||
background-color: ${props =>
|
||||
props.isActive && !props.editable ? mixin.darken('#262c49', 0.1) : `rgba(${props.theme.colors.bg.secondary})`};
|
||||
`;
|
||||
|
||||
export const ListCardInnerContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const ListCardDetails = styled.div<{ complete: boolean }>`
|
||||
overflow: hidden;
|
||||
padding: 6px 8px 2px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
${props => props.complete && 'opacity: 0.6;'}
|
||||
`;
|
||||
|
||||
export const ListCardLabels = styled.div`
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const ListCardLabel = styled.span`
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
padding: 0 8px;
|
||||
max-width: 198px;
|
||||
float: left;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin: 0 4px 4px 0;
|
||||
width: auto;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: block;
|
||||
position: relative;
|
||||
background-color: ${props => props.color};
|
||||
`;
|
||||
|
||||
export const ListCardOperation = styled.span`
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
opacity: 0.8;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background-color: ${props => mixin.darken('#262c49', 0.45)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const CardTitle = styled.span`
|
||||
clear: both;
|
||||
margin: 0 0 4px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
word-wrap: break-word;
|
||||
line-height: 16px;
|
||||
font-size: 14px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const CardMembers = styled.div`
|
||||
float: right;
|
||||
margin: 0 -2px 4px 0;
|
||||
`;
|
||||
|
||||
export const CompleteIcon = styled(CheckCircle)`
|
||||
fill: rgba(${props => props.theme.colors.success});
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const EditorContent = styled.div`
|
||||
display: flex;
|
||||
`;
|
220
frontend/src/shared/components/Card/index.tsx
Normal file
220
frontend/src/shared/components/Card/index.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
|
||||
import {
|
||||
EditorTextarea,
|
||||
EditorContent,
|
||||
CompleteIcon,
|
||||
DescriptionBadge,
|
||||
DueDateCardBadge,
|
||||
ListCardBadges,
|
||||
ListCardBadge,
|
||||
ListCardBadgeText,
|
||||
ListCardContainer,
|
||||
ListCardInnerContainer,
|
||||
ListCardDetails,
|
||||
ClockIcon,
|
||||
ListCardLabels,
|
||||
ListCardLabel,
|
||||
ListCardOperation,
|
||||
CardTitle,
|
||||
CardMembers,
|
||||
} from './Styles';
|
||||
|
||||
type DueDate = {
|
||||
isPastDue: boolean;
|
||||
formattedDate: string;
|
||||
};
|
||||
|
||||
type Checklist = {
|
||||
complete: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
taskID: string;
|
||||
taskGroupID: string;
|
||||
complete?: boolean;
|
||||
onContextMenu?: ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
description?: null | string;
|
||||
dueDate?: DueDate;
|
||||
checklists?: Checklist | null;
|
||||
labels?: Array<ProjectLabel>;
|
||||
watched?: boolean;
|
||||
wrapperProps?: any;
|
||||
members?: Array<TaskUser> | null;
|
||||
onCardMemberClick?: OnCardMemberClick;
|
||||
editable?: boolean;
|
||||
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
|
||||
onCardTitleChange?: (name: string) => void;
|
||||
};
|
||||
|
||||
const Card = React.forwardRef(
|
||||
(
|
||||
{
|
||||
wrapperProps,
|
||||
onContextMenu,
|
||||
taskID,
|
||||
taskGroupID,
|
||||
complete,
|
||||
onClick,
|
||||
labels,
|
||||
title,
|
||||
dueDate,
|
||||
description,
|
||||
checklists,
|
||||
watched,
|
||||
members,
|
||||
onCardMemberClick,
|
||||
editable,
|
||||
onEditCard,
|
||||
onCardTitleChange,
|
||||
}: Props,
|
||||
$cardRef: any,
|
||||
) => {
|
||||
const [currentCardTitle, setCardTitle] = useState(title);
|
||||
const $editorRef: any = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
setCardTitle(title);
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if ($editorRef && $editorRef.current) {
|
||||
$editorRef.current.focus();
|
||||
$editorRef.current.select();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: any) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (onEditCard) {
|
||||
onEditCard(taskGroupID, taskID, currentCardTitle);
|
||||
}
|
||||
}
|
||||
};
|
||||
const [isActive, setActive] = useState(false);
|
||||
const $innerCardRef: any = useRef(null);
|
||||
const onOpenComposer = () => {
|
||||
if (onContextMenu) {
|
||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
||||
}
|
||||
};
|
||||
const onTaskContext = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenComposer();
|
||||
};
|
||||
const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenComposer();
|
||||
};
|
||||
return (
|
||||
<ListCardContainer
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
ref={$cardRef}
|
||||
onClick={e => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
onContextMenu={onTaskContext}
|
||||
isActive={isActive}
|
||||
editable={editable}
|
||||
{...wrapperProps}
|
||||
>
|
||||
<ListCardInnerContainer ref={$innerCardRef}>
|
||||
{isActive && (
|
||||
<ListCardOperation>
|
||||
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
||||
</ListCardOperation>
|
||||
)}
|
||||
<ListCardDetails complete={complete ?? false}>
|
||||
<ListCardLabels>
|
||||
{labels &&
|
||||
labels.map(label => (
|
||||
<ListCardLabel color={label.labelColor.colorHex} key={label.id}>
|
||||
{label.name}
|
||||
</ListCardLabel>
|
||||
))}
|
||||
</ListCardLabels>
|
||||
{editable ? (
|
||||
<EditorContent>
|
||||
{complete && <CompleteIcon width={16} height={16} />}
|
||||
<EditorTextarea
|
||||
onChange={e => {
|
||||
setCardTitle(e.currentTarget.value);
|
||||
if (onCardTitleChange) {
|
||||
onCardTitleChange(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={currentCardTitle}
|
||||
ref={$editorRef}
|
||||
/>
|
||||
</EditorContent>
|
||||
) : (
|
||||
<CardTitle>
|
||||
{complete && <CompleteIcon width={16} height={16} />}
|
||||
{title}
|
||||
</CardTitle>
|
||||
)}
|
||||
<ListCardBadges>
|
||||
{watched && (
|
||||
<ListCardBadge>
|
||||
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
|
||||
</ListCardBadge>
|
||||
)}
|
||||
{dueDate && (
|
||||
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
|
||||
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
|
||||
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
|
||||
</DueDateCardBadge>
|
||||
)}
|
||||
{description && (
|
||||
<DescriptionBadge>
|
||||
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
|
||||
</DescriptionBadge>
|
||||
)}
|
||||
{checklists && (
|
||||
<ListCardBadge>
|
||||
<FontAwesomeIcon color="#6b778c" icon={faCheckSquare} size="xs" />
|
||||
<ListCardBadgeText>{`${checklists.complete}/${checklists.total}`}</ListCardBadgeText>
|
||||
</ListCardBadge>
|
||||
)}
|
||||
</ListCardBadges>
|
||||
<CardMembers>
|
||||
{members &&
|
||||
members.map(member => (
|
||||
<TaskAssignee
|
||||
key={member.id}
|
||||
size={28}
|
||||
member={member}
|
||||
onMemberProfile={$target => {
|
||||
if (onCardMemberClick) {
|
||||
onCardMemberClick($target, taskID, member.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</CardMembers>
|
||||
</ListCardDetails>
|
||||
</ListCardInnerContainer>
|
||||
</ListCardContainer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export default Card;
|
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import CardComposer from '.';
|
||||
|
||||
export default {
|
||||
component: CardComposer,
|
||||
title: 'CardComposer',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return <CardComposer isOpen onClose={action('on close')} onCreateCard={action('on create card')} />;
|
||||
};
|
35
frontend/src/shared/components/CardComposer/Styles.ts
Normal file
35
frontend/src/shared/components/CardComposer/Styles.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const CancelIcon = styled(FontAwesomeIcon)`
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
font-size: 1.25em;
|
||||
padding-left: 5px;
|
||||
`;
|
||||
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
|
||||
padding-bottom: 8px;
|
||||
display: ${props => (props.isOpen ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ComposerControls = styled.div``;
|
||||
|
||||
export const ComposerControlsSaveSection = styled.div`
|
||||
display: flex;
|
||||
float: left;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
export const ComposerControlsActionsSection = styled.div`
|
||||
float: right;
|
||||
`;
|
||||
export const AddCardButton = styled(Button)`
|
||||
margin-right: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
72
frontend/src/shared/components/CardComposer/index.tsx
Normal file
72
frontend/src/shared/components/CardComposer/index.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
|
||||
import {
|
||||
CardComposerWrapper,
|
||||
CancelIcon,
|
||||
AddCardButton,
|
||||
ComposerControls,
|
||||
ComposerControlsSaveSection,
|
||||
ComposerControlsActionsSection,
|
||||
} from './Styles';
|
||||
import Card from '../Card';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onCreateCard: (cardName: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
|
||||
const [cardName, setCardName] = useState('');
|
||||
const $cardRef = useRef<HTMLDivElement>(null);
|
||||
useOnOutsideClick($cardRef, true, onClose, null);
|
||||
useOnEscapeKeyDown(isOpen, onClose);
|
||||
return (
|
||||
<CardComposerWrapper isOpen={isOpen}>
|
||||
<Card
|
||||
title={cardName}
|
||||
ref={$cardRef}
|
||||
taskID=""
|
||||
taskGroupID=""
|
||||
editable
|
||||
onEditCard={(_taskGroupID, _taskID, name) => {
|
||||
onCreateCard(name);
|
||||
setCardName('');
|
||||
}}
|
||||
onCardTitleChange={name => {
|
||||
setCardName(name);
|
||||
}}
|
||||
/>
|
||||
<ComposerControls>
|
||||
<ComposerControlsSaveSection>
|
||||
<AddCardButton
|
||||
variant="relief"
|
||||
onClick={() => {
|
||||
onCreateCard(cardName);
|
||||
setCardName('');
|
||||
}}
|
||||
>
|
||||
Add Card
|
||||
</AddCardButton>
|
||||
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
|
||||
</ComposerControlsSaveSection>
|
||||
<ComposerControlsActionsSection />
|
||||
</ComposerControls>
|
||||
</CardComposerWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
CardComposer.propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCreateCard: PropTypes.func.isRequired,
|
||||
};
|
||||
CardComposer.defaultProps = {
|
||||
isOpen: true,
|
||||
};
|
||||
|
||||
export default CardComposer;
|
138
frontend/src/shared/components/Checklist/Checklist.stories.tsx
Normal file
138
frontend/src/shared/components/Checklist/Checklist.stories.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import { theme } from 'App/ThemeStyles';
|
||||
import produce from 'immer';
|
||||
import styled, { ThemeProvider } from 'styled-components';
|
||||
import Checklist from '.';
|
||||
|
||||
export default {
|
||||
component: Checklist,
|
||||
title: 'Checklist',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
width: 552px;
|
||||
margin: 25px;
|
||||
border: 1px solid rgba(${props => props.theme.colors.bg.primary});
|
||||
`;
|
||||
|
||||
const defaultItems = [
|
||||
{
|
||||
id: '1',
|
||||
position: 1,
|
||||
taskChecklistID: '1',
|
||||
complete: false,
|
||||
name: 'Tasks',
|
||||
assigned: null,
|
||||
dueDate: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
taskChecklistID: '1',
|
||||
position: 2,
|
||||
complete: false,
|
||||
name: 'Projects',
|
||||
assigned: null,
|
||||
dueDate: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
position: 3,
|
||||
taskChecklistID: '1',
|
||||
complete: false,
|
||||
name: 'Teams',
|
||||
assigned: null,
|
||||
dueDate: null,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
position: 4,
|
||||
complete: false,
|
||||
taskChecklistID: '1',
|
||||
name: 'Organizations',
|
||||
assigned: null,
|
||||
dueDate: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const Default = () => {
|
||||
const [checklistName, setChecklistName] = useState('Checklist');
|
||||
const [items, setItems] = useState(defaultItems);
|
||||
const onToggleItem = (itemID: string, complete: boolean) => {
|
||||
setItems(
|
||||
produce(items, draftState => {
|
||||
const idx = items.findIndex(item => item.id === itemID);
|
||||
if (idx !== -1) {
|
||||
draftState[idx] = {
|
||||
...draftState[idx],
|
||||
complete,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<BaseStyles />
|
||||
<NormalizeStyles />
|
||||
<ThemeProvider theme={theme}>
|
||||
<Container>
|
||||
<Checklist
|
||||
name={checklistName}
|
||||
checklistID="checklist-one"
|
||||
items={items}
|
||||
onDeleteChecklist={action('delete checklist')}
|
||||
onChangeName={currentName => {
|
||||
setChecklistName(currentName);
|
||||
}}
|
||||
onAddItem={itemName => {
|
||||
let position = 1;
|
||||
const lastItem = items[-1];
|
||||
if (lastItem) {
|
||||
position = lastItem.position * 2 + 1;
|
||||
}
|
||||
setItems([
|
||||
...items,
|
||||
{
|
||||
id: `${Math.random()}`,
|
||||
name: itemName,
|
||||
complete: false,
|
||||
assigned: null,
|
||||
dueDate: null,
|
||||
position,
|
||||
taskChecklistID: '1',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
onDeleteItem={itemID => {
|
||||
console.log(`itemID ${itemID}`);
|
||||
setItems(items.filter(item => item.id !== itemID));
|
||||
}}
|
||||
onChangeItemName={(itemID, currentName) => {
|
||||
setItems(
|
||||
produce(items, draftState => {
|
||||
const idx = items.findIndex(item => item.id === itemID);
|
||||
if (idx !== -1) {
|
||||
draftState[idx] = {
|
||||
...draftState[idx],
|
||||
name: currentName,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onToggleItem={onToggleItem}
|
||||
/>
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
};
|
596
frontend/src/shared/components/Checklist/index.tsx
Normal file
596
frontend/src/shared/components/Checklist/index.tsx
Normal file
@ -0,0 +1,596 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CheckSquare, Trash, Square, CheckSquareOutline, Clock, Cross, AccountPlus } from 'shared/icons';
|
||||
import Button from 'shared/components/Button';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import Control from 'react-select/src/components/Control';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const WindowTitle = styled.div`
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
margin: 0 0 4px 40px;
|
||||
`;
|
||||
|
||||
const WindowTitleIcon = styled(CheckSquareOutline)`
|
||||
top: 10px;
|
||||
left: -40px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
const WindowChecklistTitle = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
`;
|
||||
|
||||
const WindowTitleText = styled.h3`
|
||||
cursor: pointer;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
margin: 6px 0;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
min-height: 18px;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
min-width: 40px;
|
||||
`;
|
||||
|
||||
const WindowOptions = styled.div`
|
||||
margin: 0 2px 0 auto;
|
||||
float: right;
|
||||
`;
|
||||
|
||||
const DeleteButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
const ChecklistProgress = styled.div`
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
`;
|
||||
const ChecklistProgressPercent = styled.span`
|
||||
color: #5e6c84;
|
||||
font-size: 11px;
|
||||
line-height: 10px;
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: -1px;
|
||||
text-align: center;
|
||||
width: 32px;
|
||||
`;
|
||||
|
||||
const ChecklistProgressBar = styled.div`
|
||||
background: rgba(${props => props.theme.colors.bg.primary});
|
||||
border-radius: 4px;
|
||||
clear: both;
|
||||
height: 8px;
|
||||
margin: 0 0 0 40px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
const ChecklistProgressBarCurrent = styled.div<{ width: number }>`
|
||||
width: ${props => props.width}%;
|
||||
background: rgba(${props => (props.width === 100 ? props.theme.colors.success : props.theme.colors.primary)});
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: width 0.14s ease-in, background 0.14s ease-in;
|
||||
`;
|
||||
|
||||
const ChecklistItems = styled.div`
|
||||
min-height: 8px;
|
||||
`;
|
||||
|
||||
const ChecklistItemUncheckedIcon = styled(Square)``;
|
||||
|
||||
const ChecklistIcon = styled.div`
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
margin: 10px;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
const ChecklistItemCheckedIcon = styled(CheckSquare)`
|
||||
fill: rgba(${props => props.theme.colors.primary});
|
||||
`;
|
||||
|
||||
const ChecklistItemDetails = styled.div`
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
`;
|
||||
const ChecklistItemRow = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const ChecklistItemTextControls = styled.div`
|
||||
padding: 6px 0;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
`;
|
||||
|
||||
const ChecklistItemText = styled.span<{ complete: boolean }>`
|
||||
color: ${props => (props.complete ? '#5e6c84' : `rgba(${props.theme.colors.text.primary})`)};
|
||||
${props => props.complete && 'text-decoration: line-through;'}
|
||||
line-height: 20px;
|
||||
font-size: 16px;
|
||||
|
||||
min-height: 20px;
|
||||
margin-bottom: 0;
|
||||
align-self: center;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const ChecklistControls = styled.div`
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
float: right;
|
||||
`;
|
||||
|
||||
const ControlButton = styled.div`
|
||||
opacity: 0;
|
||||
margin-left: 4px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.8);
|
||||
&:hover {
|
||||
background-color: rgba(${props => props.theme.colors.primary}, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
const ChecklistNameEditorWrapper = styled.div`
|
||||
display: block;
|
||||
float: left;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 8px;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
`;
|
||||
export const ChecklistNameEditor = styled(TextareaAutosize)`
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
resize: none;
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
max-height: 162px;
|
||||
min-height: 54px;
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
border: 1px solid rgba(${props => props.theme.colors.primary});
|
||||
border-radius: 3px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
|
||||
border-color: rgba(${props => props.theme.colors.border});
|
||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
||||
&:focus {
|
||||
border-color: rgba(${props => props.theme.colors.primary});
|
||||
}
|
||||
`;
|
||||
|
||||
const AssignUserButton = styled(AccountPlus)`
|
||||
fill: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
||||
|
||||
const ClockButton = styled(Clock)`
|
||||
fill: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
||||
|
||||
const TrashButton = styled(Trash)`
|
||||
fill: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
||||
|
||||
const ChecklistItemWrapper = styled.div`
|
||||
user-select: none;
|
||||
clear: both;
|
||||
padding-left: 40px;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
transform-origin: left bottom;
|
||||
transition-property: transform, opacity, height, padding, margin;
|
||||
transition-duration: 0.14s;
|
||||
transition-timing-function: ease-in;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
||||
}
|
||||
&:hover ${ControlButton} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const EditControls = styled.div`
|
||||
clear: both;
|
||||
display: flex;
|
||||
padding-bottom: 9px;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const SaveButton = styled(Button)`
|
||||
margin-right: 4px;
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
const CancelButton = styled.div`
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
& svg {
|
||||
fill: rgba(${props => props.theme.colors.text.primary});
|
||||
}
|
||||
&:hover svg {
|
||||
fill: rgba(${props => props.theme.colors.text.secondary});
|
||||
}
|
||||
`;
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const EditableDeleteButton = styled.button`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
margin: 0 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(${props => props.theme.colors.primary}, 0.8);
|
||||
}
|
||||
`;
|
||||
|
||||
const NewItemButton = styled(Button)`
|
||||
padding: 6px 8px;
|
||||
`;
|
||||
|
||||
const ChecklistNewItem = styled.div`
|
||||
margin: 8px 0;
|
||||
margin-left: 40px;
|
||||
`;
|
||||
|
||||
type ChecklistItemProps = {
|
||||
itemID: string;
|
||||
complete: boolean;
|
||||
name: string;
|
||||
onChangeName: (itemID: string, currentName: string) => void;
|
||||
onToggleItem: (itemID: string, complete: boolean) => void;
|
||||
onDeleteItem: (itemID: string) => void;
|
||||
};
|
||||
|
||||
const ChecklistItem: React.FC<ChecklistItemProps> = ({
|
||||
itemID,
|
||||
complete,
|
||||
name,
|
||||
onChangeName,
|
||||
onToggleItem,
|
||||
onDeleteItem,
|
||||
}) => {
|
||||
const $item = useRef<HTMLDivElement>(null);
|
||||
const $editor = useRef<HTMLTextAreaElement>(null);
|
||||
const [editting, setEditting] = useState(false);
|
||||
const [currentName, setCurrentName] = useState(name);
|
||||
useEffect(() => {
|
||||
if (editting && $editor && $editor.current) {
|
||||
$editor.current.focus();
|
||||
$editor.current.select();
|
||||
}
|
||||
}, [editting]);
|
||||
useOnOutsideClick($item, true, () => setEditting(false), null);
|
||||
return (
|
||||
<ChecklistItemWrapper ref={$item}>
|
||||
<ChecklistIcon
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onToggleItem(itemID, !complete);
|
||||
}}
|
||||
>
|
||||
{complete ? (
|
||||
<ChecklistItemCheckedIcon width={20} height={20} />
|
||||
) : (
|
||||
<ChecklistItemUncheckedIcon width={20} height={20} />
|
||||
)}
|
||||
</ChecklistIcon>
|
||||
{editting ? (
|
||||
<>
|
||||
<ChecklistNameEditorWrapper>
|
||||
<ChecklistNameEditor
|
||||
ref={$editor}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
onChangeName(itemID, currentName);
|
||||
setEditting(false);
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
setCurrentName(e.currentTarget.value);
|
||||
}}
|
||||
value={currentName}
|
||||
/>
|
||||
</ChecklistNameEditorWrapper>
|
||||
<EditControls>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
onChangeName(itemID, currentName);
|
||||
setEditting(false);
|
||||
}}
|
||||
variant="relief"
|
||||
>
|
||||
Save
|
||||
</SaveButton>
|
||||
<CancelButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditting(false);
|
||||
}}
|
||||
>
|
||||
<Cross width={20} height={20} />
|
||||
</CancelButton>
|
||||
<Spacer />
|
||||
<EditableDeleteButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditting(false);
|
||||
onDeleteItem(itemID);
|
||||
}}
|
||||
>
|
||||
<Trash width={16} height={16} />
|
||||
</EditableDeleteButton>
|
||||
</EditControls>
|
||||
</>
|
||||
) : (
|
||||
<ChecklistItemDetails
|
||||
onClick={() => {
|
||||
setEditting(true);
|
||||
}}
|
||||
>
|
||||
<ChecklistItemRow>
|
||||
<ChecklistItemTextControls>
|
||||
<ChecklistItemText complete={complete}>{name}</ChecklistItemText>
|
||||
<ChecklistControls>
|
||||
<ControlButton>
|
||||
<AssignUserButton width={14} height={14} />
|
||||
</ControlButton>
|
||||
<ControlButton>
|
||||
<ClockButton width={14} height={14} />
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDeleteItem(itemID);
|
||||
}}
|
||||
>
|
||||
<TrashButton width={14} height={14} />
|
||||
</ControlButton>
|
||||
</ChecklistControls>
|
||||
</ChecklistItemTextControls>
|
||||
</ChecklistItemRow>
|
||||
</ChecklistItemDetails>
|
||||
)}
|
||||
</ChecklistItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type AddNewItemProps = {
|
||||
onAddItem: (name: string) => void;
|
||||
};
|
||||
|
||||
const AddNewItem: React.FC<AddNewItemProps> = ({ onAddItem }) => {
|
||||
const $editor = useRef<HTMLTextAreaElement>(null);
|
||||
const $wrapper = useRef<HTMLDivElement>(null);
|
||||
const [currentName, setCurrentName] = useState('');
|
||||
const [editting, setEditting] = useState(false);
|
||||
useEffect(() => {
|
||||
if (editting && $editor && $editor.current) {
|
||||
$editor.current.focus();
|
||||
$editor.current.select();
|
||||
}
|
||||
}, [editting]);
|
||||
useOnOutsideClick($wrapper, true, () => setEditting(false), null);
|
||||
return (
|
||||
<ChecklistNewItem ref={$wrapper}>
|
||||
{editting ? (
|
||||
<>
|
||||
<ChecklistNameEditorWrapper>
|
||||
<ChecklistNameEditor
|
||||
ref={$editor}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onAddItem(currentName);
|
||||
setCurrentName('');
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
setCurrentName(e.currentTarget.value);
|
||||
}}
|
||||
value={currentName}
|
||||
/>
|
||||
</ChecklistNameEditorWrapper>
|
||||
<EditControls>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
onAddItem(currentName);
|
||||
setCurrentName('');
|
||||
if (editting && $editor && $editor.current) {
|
||||
$editor.current.focus();
|
||||
$editor.current.select();
|
||||
}
|
||||
}}
|
||||
variant="relief"
|
||||
>
|
||||
Save
|
||||
</SaveButton>
|
||||
<CancelButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditting(false);
|
||||
}}
|
||||
>
|
||||
<Cross width={20} height={20} />
|
||||
</CancelButton>
|
||||
</EditControls>
|
||||
</>
|
||||
) : (
|
||||
<NewItemButton onClick={() => setEditting(true)}>Add an item</NewItemButton>
|
||||
)}
|
||||
</ChecklistNewItem>
|
||||
);
|
||||
};
|
||||
|
||||
type ChecklistTitleEditorProps = {
|
||||
name: string;
|
||||
onChangeName: (item: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const ChecklistTitleEditor = React.forwardRef(
|
||||
({ name, onChangeName, onCancel }: ChecklistTitleEditorProps, $name: any) => {
|
||||
const [currentName, setCurrentName] = useState(name);
|
||||
return (
|
||||
<>
|
||||
<ChecklistNameEditor
|
||||
ref={$name}
|
||||
value={currentName}
|
||||
onChange={e => {
|
||||
setCurrentName(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
onChangeName(currentName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EditControls>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
onChangeName(currentName);
|
||||
}}
|
||||
variant="relief"
|
||||
>
|
||||
Save
|
||||
</SaveButton>
|
||||
<CancelButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<Cross width={20} height={20} />
|
||||
</CancelButton>
|
||||
</EditControls>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
type ChecklistProps = {
|
||||
checklistID: string;
|
||||
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
|
||||
name: string;
|
||||
onChangeName: (item: string) => void;
|
||||
onToggleItem: (taskID: string, complete: boolean) => void;
|
||||
onChangeItemName: (itemID: string, currentName: string) => void;
|
||||
onDeleteItem: (itemID: string) => void;
|
||||
onAddItem: (itemName: string) => void;
|
||||
items: Array<TaskChecklistItem>;
|
||||
};
|
||||
|
||||
const Checklist: React.FC<ChecklistProps> = ({
|
||||
checklistID,
|
||||
onDeleteChecklist,
|
||||
name,
|
||||
items,
|
||||
onToggleItem,
|
||||
onAddItem,
|
||||
onChangeItemName,
|
||||
onChangeName,
|
||||
onDeleteItem,
|
||||
}) => {
|
||||
const $name = useRef<HTMLTextAreaElement>(null);
|
||||
const complete = items.reduce((prev, item) => prev + (item.complete ? 1 : 0), 0);
|
||||
const percent = items.length === 0 ? 0 : Math.floor((complete / items.length) * 100);
|
||||
const [editting, setEditting] = useState(false);
|
||||
// useOnOutsideClick($name, true, () => setEditting(false), null);
|
||||
useEffect(() => {
|
||||
if (editting && $name && $name.current) {
|
||||
$name.current.focus();
|
||||
$name.current.select();
|
||||
}
|
||||
}, [editting]);
|
||||
return (
|
||||
<Wrapper>
|
||||
<WindowTitle>
|
||||
<WindowTitleIcon width={24} height={24} />
|
||||
{editting ? (
|
||||
<ChecklistTitleEditor
|
||||
ref={$name}
|
||||
name={name}
|
||||
onChangeName={currentName => {
|
||||
onChangeName(currentName);
|
||||
setEditting(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setEditting(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<WindowChecklistTitle>
|
||||
<WindowTitleText onClick={() => setEditting(true)}>{name}</WindowTitleText>
|
||||
<WindowOptions>
|
||||
<DeleteButton
|
||||
onClick={$target => {
|
||||
onDeleteChecklist($target, checklistID);
|
||||
}}
|
||||
color="danger"
|
||||
variant="outline"
|
||||
>
|
||||
Delete
|
||||
</DeleteButton>
|
||||
</WindowOptions>
|
||||
</WindowChecklistTitle>
|
||||
)}
|
||||
</WindowTitle>
|
||||
<ChecklistProgress>
|
||||
<ChecklistProgressPercent>{`${percent}%`}</ChecklistProgressPercent>
|
||||
<ChecklistProgressBar>
|
||||
<ChecklistProgressBarCurrent width={percent} />
|
||||
</ChecklistProgressBar>
|
||||
</ChecklistProgress>
|
||||
<ChecklistItems>
|
||||
{items
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(item => (
|
||||
<ChecklistItem
|
||||
key={item.id}
|
||||
itemID={item.id}
|
||||
name={item.name}
|
||||
complete={item.complete}
|
||||
onDeleteItem={onDeleteItem}
|
||||
onChangeName={onChangeItemName}
|
||||
onToggleItem={onToggleItem}
|
||||
/>
|
||||
))}
|
||||
</ChecklistItems>
|
||||
<AddNewItem onAddItem={onAddItem} />
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checklist;
|
@ -0,0 +1,65 @@
|
||||
import React, { createRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import DropdownMenu from '.';
|
||||
|
||||
export default {
|
||||
component: DropdownMenu,
|
||||
title: 'DropdownMenu',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
{ name: 'darkBlue', value: '#262c49', default: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
const Button = styled.div`
|
||||
font-size: 18px;
|
||||
padding: 15px 20px;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
`;
|
||||
|
||||
export const Default = () => {
|
||||
const [menu, setMenu] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
isOpen: false,
|
||||
});
|
||||
const $buttonRef: any = createRef();
|
||||
const onClick = () => {
|
||||
console.log($buttonRef.current.getBoundingClientRect());
|
||||
setMenu({
|
||||
isOpen: !menu.isOpen,
|
||||
left: $buttonRef.current.getBoundingClientRect().right,
|
||||
top: $buttonRef.current.getBoundingClientRect().bottom,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Button onClick={onClick} ref={$buttonRef}>
|
||||
Click me
|
||||
</Button>
|
||||
</Container>
|
||||
{menu.isOpen && (
|
||||
<DropdownMenu
|
||||
onAdminConsole={action('admin')}
|
||||
onCloseDropdown={() => {
|
||||
setMenu({ top: 0, left: 0, isOpen: false });
|
||||
}}
|
||||
onLogout={action('on logout')}
|
||||
left={menu.left}
|
||||
top={menu.top}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
74
frontend/src/shared/components/DropdownMenu/Styles.ts
Normal file
74
frontend/src/shared/components/DropdownMenu/Styles.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import styled from 'styled-components/macro';
|
||||
|
||||
export const Container = styled.div<{ left: number; top: number }>`
|
||||
position: absolute;
|
||||
left: ${props => props.left}px;
|
||||
top: ${props => props.top}px;
|
||||
position: absolute;
|
||||
padding-top: 10px;
|
||||
height: auto;
|
||||
width: auto;
|
||||
transform: translate(-100%);
|
||||
transition: opacity 0.25s, transform 0.25s, width 0.3s ease;
|
||||
z-index: 40000;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
margin: 0;
|
||||
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const WrapperDiamond = styled.div`
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
transform: rotate(45deg) translate(-7px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
|
||||
background: #262c49;
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const ActionsList = styled.ul`
|
||||
min-width: 9rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export const ActionItem = styled.li`
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: 0.5rem !important;
|
||||
padding-bottom: 0.5rem !important;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionTitle = styled.span`
|
||||
margin-left: 0.5rem;
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
height: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
margin: 0.25rem !important;
|
||||
`;
|
67
frontend/src/shared/components/DropdownMenu/index.tsx
Normal file
67
frontend/src/shared/components/DropdownMenu/index.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React, { useRef } from 'react';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { Exit, User, Cog } from 'shared/icons';
|
||||
import { Separator, Container, WrapperDiamond, Wrapper, ActionsList, ActionItem, ActionTitle } from './Styles';
|
||||
|
||||
type DropdownMenuProps = {
|
||||
left: number;
|
||||
top: number;
|
||||
onLogout: () => void;
|
||||
onCloseDropdown: () => void;
|
||||
onAdminConsole: () => void;
|
||||
};
|
||||
|
||||
const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top, onLogout, onCloseDropdown, onAdminConsole }) => {
|
||||
const $containerRef = useRef<HTMLDivElement>(null);
|
||||
useOnOutsideClick($containerRef, true, onCloseDropdown, null);
|
||||
return (
|
||||
<Container ref={$containerRef} left={left} top={top}>
|
||||
<Wrapper>
|
||||
<ActionItem onClick={onAdminConsole}>
|
||||
<User size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Profile</ActionTitle>
|
||||
</ActionItem>
|
||||
<Separator />
|
||||
<ActionsList>
|
||||
<ActionItem onClick={onLogout}>
|
||||
<Exit size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Logout</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</Wrapper>
|
||||
<WrapperDiamond />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
type ProfileMenuProps = {
|
||||
onProfile: () => void;
|
||||
onLogout: () => void;
|
||||
onAdminConsole: () => void;
|
||||
};
|
||||
|
||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ onAdminConsole, onProfile, onLogout }) => {
|
||||
return (
|
||||
<>
|
||||
<ActionItem onClick={onAdminConsole}>
|
||||
<Cog size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Admin Console</ActionTitle>
|
||||
</ActionItem>
|
||||
<Separator />
|
||||
<ActionItem onClick={onProfile}>
|
||||
<User size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Profile</ActionTitle>
|
||||
</ActionItem>
|
||||
<ActionsList>
|
||||
<ActionItem onClick={onLogout}>
|
||||
<Exit size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Logout</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfileMenu };
|
||||
|
||||
export default DropdownMenu;
|
@ -0,0 +1,74 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import { theme } from 'App/ThemeStyles';
|
||||
import styled, { ThemeProvider } from 'styled-components';
|
||||
import { Popup } from '../PopupMenu';
|
||||
import DueDateManager from '.';
|
||||
|
||||
const PopupWrapper = styled.div`
|
||||
width: 310px;
|
||||
`;
|
||||
|
||||
export default {
|
||||
component: DueDateManager,
|
||||
title: 'DueDateManager',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<ThemeProvider theme={theme}>
|
||||
<PopupWrapper>
|
||||
<Popup title={null} tab={0}>
|
||||
<DueDateManager
|
||||
task={{
|
||||
id: '1',
|
||||
taskGroup: { name: 'General', id: '1', position: 1 },
|
||||
name: 'Hello, world',
|
||||
position: 1,
|
||||
labels: [
|
||||
{
|
||||
id: 'soft-skills',
|
||||
assignedDate: new Date().toString(),
|
||||
projectLabel: {
|
||||
createdDate: new Date().toString(),
|
||||
id: 'label-soft-skills',
|
||||
name: 'Soft Skills',
|
||||
labelColor: {
|
||||
id: '1',
|
||||
name: 'white',
|
||||
colorHex: '#fff',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
description: 'hello!',
|
||||
assigned: [
|
||||
{
|
||||
id: '1',
|
||||
profileIcon: { url: null, initials: null, bgColor: null },
|
||||
fullName: 'Jordan Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCancel={action('cancel')}
|
||||
onDueDateChange={action('due date change')}
|
||||
onRemoveDueDate={action('remove due date')}
|
||||
/>
|
||||
</Popup>
|
||||
</PopupWrapper>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
};
|
134
frontend/src/shared/components/DueDateManager/Styles.ts
Normal file
134
frontend/src/shared/components/DueDateManager/Styles.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Input from 'shared/components/Input';
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
display: flex
|
||||
flex-direction: column;
|
||||
& .react-datepicker {
|
||||
background: #262c49;
|
||||
font-family: 'Droid Sans', sans-serif;
|
||||
border: none;
|
||||
}
|
||||
& .react-datepicker__triangle {
|
||||
display: none;
|
||||
}
|
||||
& .react-datepicker-popper {
|
||||
z-index: 10000;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
& .react-datepicker-time__header {
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
}
|
||||
& .react-datepicker__time-list-item {
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
}
|
||||
& .react-datepicker__time-container .react-datepicker__time
|
||||
.react-datepicker__time-box ul.react-datepicker__time-list
|
||||
li.react-datepicker__time-list-item:hover {
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
background: rgba(${props => props.theme.colors.bg.secondary});
|
||||
}
|
||||
& .react-datepicker__time-container .react-datepicker__time {
|
||||
background: rgba(${props => props.theme.colors.bg.primary});
|
||||
}
|
||||
& .react-datepicker--time-only {
|
||||
background: rgba(${props => props.theme.colors.bg.primary});
|
||||
border: 1px solid rgba(${props => props.theme.colors.border});
|
||||
}
|
||||
|
||||
& .react-datepicker * {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
& .react-datepicker__day-name {
|
||||
color: #c2c6dc;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
padding: 4px;
|
||||
font-size: 12px40px
|
||||
line-height: 40px;
|
||||
}
|
||||
& .react-datepicker__day-name:hover {
|
||||
background: #10163a;
|
||||
}
|
||||
& .react-datepicker__month {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .react-datepicker__day,
|
||||
& .react-datepicker__time-name {
|
||||
color: #c2c6dc;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
& .react-datepicker__day--outside-month {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .react-datepicker__day:hover {
|
||||
border-radius: 50%;
|
||||
background: #10163a;
|
||||
}
|
||||
& .react-datepicker__day--selected {
|
||||
border-radius: 50%;
|
||||
background: rgba(115, 103, 240);
|
||||
color: #fff;
|
||||
}
|
||||
& .react-datepicker__day--selected:hover {
|
||||
border-radius: 50%;
|
||||
background: rgba(115, 103, 240);
|
||||
color: #fff;
|
||||
}
|
||||
& .react-datepicker__header {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
& .react-datepicker__header--time {
|
||||
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export const DueDatePickerWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const ConfirmAddDueDate = styled(Button)`
|
||||
margin: 0 4px 0 0;
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
export const RemoveDueDate = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
margin: 0 0 0 4px;
|
||||
`;
|
||||
|
||||
export const CancelDueDate = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const DueDateInput = styled(Input)`
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
export const ActionWrapper = styled.div`
|
||||
padding-top: 8px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
290
frontend/src/shared/components/DueDateManager/index.tsx
Normal file
290
frontend/src/shared/components/DueDateManager/index.tsx
Normal file
@ -0,0 +1,290 @@
|
||||
import React, { useState, useEffect, forwardRef } from 'react';
|
||||
import moment from 'moment';
|
||||
import styled from 'styled-components';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import { Cross } from 'shared/icons';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
Wrapper,
|
||||
ActionWrapper,
|
||||
RemoveDueDate,
|
||||
DueDateInput,
|
||||
DueDatePickerWrapper,
|
||||
ConfirmAddDueDate,
|
||||
CancelDueDate,
|
||||
} from './Styles';
|
||||
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { getYear, getMonth } from 'date-fns';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
type DueDateManagerProps = {
|
||||
task: Task;
|
||||
onDueDateChange: (task: Task, newDueDate: Date) => void;
|
||||
onRemoveDueDate: (task: Task) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const Form = styled.form`
|
||||
padding-top: 25px;
|
||||
`;
|
||||
|
||||
const FormField = styled.div`
|
||||
width: 50%;
|
||||
display: inline-block;
|
||||
`;
|
||||
const HeaderSelectLabel = styled.div`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 9999;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
text-decoration: underline;
|
||||
margin: 6px 0;
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
color: #c2c6dc;
|
||||
|
||||
&:hover {
|
||||
background: rgba(115, 103, 240);
|
||||
color: #c2c6dc;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderSelect = styled.select`
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 4px 6px;
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
|
||||
&:hover {
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(115, 103, 240);
|
||||
outline: none !important;
|
||||
box-shadow: none;
|
||||
color: #c2c6dc;
|
||||
}
|
||||
|
||||
&::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
z-index: 9998;
|
||||
margin: 0;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
const HeaderButton = styled.button`
|
||||
cursor: pointer;
|
||||
color: #c2c6dc;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 6px 10px;
|
||||
margin: 6px 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(115, 103, 240);
|
||||
color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderActions = styled.div`
|
||||
position: relative;
|
||||
text-align: center;
|
||||
& > button:first-child {
|
||||
float: left;
|
||||
}
|
||||
& > button:last-child {
|
||||
float: right;
|
||||
}
|
||||
`;
|
||||
|
||||
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
|
||||
const now = moment();
|
||||
const [textStartDate, setTextStartDate] = useState(now.format('YYYY-MM-DD'));
|
||||
const [startDate, setStartDate] = useState(new Date());
|
||||
useEffect(() => {
|
||||
setTextStartDate(moment(startDate).format('YYYY-MM-DD'));
|
||||
}, [startDate]);
|
||||
|
||||
const [textEndTime, setTextEndTime] = useState(now.format('h:mm A'));
|
||||
const [endTime, setEndTime] = useState(now.toDate());
|
||||
useEffect(() => {
|
||||
setTextEndTime(moment(endTime).format('h:mm A'));
|
||||
}, [endTime]);
|
||||
|
||||
const years = _.range(2010, getYear(new Date()) + 10, 1);
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
const { register, handleSubmit, errors, setValue, setError, formState } = useForm<DueDateFormData>();
|
||||
const saveDueDate = (data: any) => {
|
||||
console.log(data);
|
||||
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
|
||||
if (newDate.isValid()) {
|
||||
onDueDateChange(task, newDate.toDate());
|
||||
}
|
||||
};
|
||||
console.log(errors);
|
||||
register({ name: 'endTime' }, { required: 'End time is required' });
|
||||
useEffect(() => {
|
||||
setValue('endTime', now.format('h:mm A'));
|
||||
}, []);
|
||||
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
|
||||
return (
|
||||
<DueDateInput
|
||||
id="endTime"
|
||||
name="endTime"
|
||||
ref={$ref}
|
||||
onChange={e => {
|
||||
console.log(`onCahnge ${e.currentTarget.value}`);
|
||||
setTextEndTime(e.currentTarget.value);
|
||||
setValue('endTime', e.currentTarget.value);
|
||||
}}
|
||||
width="100%"
|
||||
variant="alternate"
|
||||
label="Date"
|
||||
onClick={onClick}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
});
|
||||
console.log(`textStartDate ${textStartDate}`);
|
||||
return (
|
||||
<Wrapper>
|
||||
<Form onSubmit={handleSubmit(saveDueDate)}>
|
||||
<FormField>
|
||||
<DueDateInput
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
width="100%"
|
||||
variant="alternate"
|
||||
label="Date"
|
||||
onChange={e => {
|
||||
setTextStartDate(e.currentTarget.value);
|
||||
}}
|
||||
value={textStartDate}
|
||||
ref={register({
|
||||
required: 'End date is required.',
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<DatePicker
|
||||
selected={endTime}
|
||||
onChange={date => {
|
||||
const changedDate = moment(date ?? new Date());
|
||||
console.log(`changed ${date}`);
|
||||
setEndTime(changedDate.toDate());
|
||||
setValue('endTime', changedDate.format('h:mm A'));
|
||||
}}
|
||||
showTimeSelect
|
||||
showTimeSelectOnly
|
||||
timeIntervals={15}
|
||||
timeCaption="Time"
|
||||
dateFormat="h:mm aa"
|
||||
customInput={<CustomTimeInput />}
|
||||
/>
|
||||
</FormField>
|
||||
<DueDatePickerWrapper>
|
||||
<DatePicker
|
||||
useWeekdaysShort
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
changeYear,
|
||||
changeMonth,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<HeaderActions>
|
||||
<HeaderButton onClick={decreaseMonth} disabled={prevMonthButtonDisabled}>
|
||||
Prev
|
||||
</HeaderButton>
|
||||
<HeaderSelectLabel>
|
||||
{months[date.getMonth()]}
|
||||
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value))}>
|
||||
{years.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</HeaderSelect>
|
||||
</HeaderSelectLabel>
|
||||
<HeaderSelectLabel>
|
||||
{date.getFullYear()}
|
||||
<HeaderSelect
|
||||
value={months[getMonth(date)]}
|
||||
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
|
||||
>
|
||||
{months.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</HeaderSelect>
|
||||
</HeaderSelectLabel>
|
||||
|
||||
<HeaderButton onClick={increaseMonth} disabled={nextMonthButtonDisabled}>
|
||||
Next
|
||||
</HeaderButton>
|
||||
</HeaderActions>
|
||||
)}
|
||||
selected={startDate}
|
||||
inline
|
||||
onChange={date => {
|
||||
setStartDate(date ?? new Date());
|
||||
}}
|
||||
/>
|
||||
</DueDatePickerWrapper>
|
||||
<ActionWrapper>
|
||||
<ConfirmAddDueDate type="submit" onClick={() => {}}>
|
||||
Save
|
||||
</ConfirmAddDueDate>
|
||||
<RemoveDueDate
|
||||
variant="outline"
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
onRemoveDueDate(task);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</RemoveDueDate>
|
||||
</ActionWrapper>
|
||||
</Form>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DueDateManager;
|
43
frontend/src/shared/components/Input/Input.stories.tsx
Normal file
43
frontend/src/shared/components/Input/Input.stories.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import { theme } from 'App/ThemeStyles';
|
||||
import styled, { ThemeProvider } from 'styled-components';
|
||||
|
||||
import Input from '.';
|
||||
import { User } from 'shared/icons';
|
||||
|
||||
export default {
|
||||
component: Input,
|
||||
title: 'Input',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background: rgba(${props => props.theme.colors.bg.primary});
|
||||
padding: 45px;
|
||||
margin: 25px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<ThemeProvider theme={theme}>
|
||||
<Wrapper>
|
||||
<Input label="Label placeholder" />
|
||||
<Input width="100%" placeholder="Placeholder" />
|
||||
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
|
||||
</Wrapper>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
};
|
152
frontend/src/shared/components/Input/index.tsx
Normal file
152
frontend/src/shared/components/Input/index.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
|
||||
const InputWrapper = styled.div<{ width: string }>`
|
||||
position: relative;
|
||||
width: ${props => props.width};
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
|
||||
margin-bottom: 2.2rem;
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
const InputLabel = styled.span<{ width: string }>`
|
||||
width: ${props => props.width};
|
||||
padding: 0.7rem !important;
|
||||
color: #c2c6dc;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
cursor: text;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const InputInput = styled.input<{
|
||||
hasValue: boolean;
|
||||
hasIcon: boolean;
|
||||
width: string;
|
||||
focusBg: string;
|
||||
borderColor: string;
|
||||
}>`
|
||||
width: ${props => props.width};
|
||||
font-size: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-color: ${props => props.borderColor};
|
||||
background: #262c49;
|
||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
|
||||
${props => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
|
||||
line-height: 16px;
|
||||
color: #c2c6dc;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
&:focus {
|
||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(115, 103, 240);
|
||||
background: ${props => props.focusBg};
|
||||
}
|
||||
&:focus ~ ${InputLabel} {
|
||||
color: rgba(115, 103, 240);
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
${props =>
|
||||
props.hasValue &&
|
||||
css`
|
||||
& ~ ${InputLabel} {
|
||||
color: rgba(115, 103, 240);
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Icon = styled.div`
|
||||
display: flex;
|
||||
left: 16px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
type InputProps = {
|
||||
variant?: 'normal' | 'alternate';
|
||||
label?: string;
|
||||
width?: string;
|
||||
floatingLabel?: boolean;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
autocomplete?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
value?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const Input = React.forwardRef(
|
||||
(
|
||||
{
|
||||
width = 'auto',
|
||||
variant = 'normal',
|
||||
autocomplete,
|
||||
label,
|
||||
placeholder,
|
||||
icon,
|
||||
name,
|
||||
onChange,
|
||||
className,
|
||||
onClick,
|
||||
floatingLabel,
|
||||
value: initialValue,
|
||||
id,
|
||||
}: InputProps,
|
||||
$ref: any,
|
||||
) => {
|
||||
const [value, setValue] = useState(initialValue ?? '');
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
setValue(initialValue);
|
||||
}
|
||||
}, [initialValue]);
|
||||
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
|
||||
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.currentTarget.value);
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<InputWrapper className={className} width={width}>
|
||||
<InputInput
|
||||
hasValue={floatingLabel || value !== ''}
|
||||
ref={$ref}
|
||||
id={id}
|
||||
name={name}
|
||||
onClick={onClick}
|
||||
onChange={handleChange}
|
||||
autoComplete={autocomplete ? 'on' : 'off'}
|
||||
value={value}
|
||||
hasIcon={typeof icon !== 'undefined'}
|
||||
width={width}
|
||||
placeholder={placeholder}
|
||||
focusBg={focusBg}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
{label && <InputLabel width={width}>{label}</InputLabel>}
|
||||
<Icon>{icon && icon}</Icon>
|
||||
</InputWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Input;
|
177
frontend/src/shared/components/List/List.stories.tsx
Normal file
177
frontend/src/shared/components/List/List.stories.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import React, { createRef } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import Card from 'shared/components/Card';
|
||||
import CardComposer from 'shared/components/CardComposer';
|
||||
import LabelColors from 'shared/constants/labelColors';
|
||||
import List, { ListCards } from '.';
|
||||
|
||||
export default {
|
||||
component: List,
|
||||
title: 'List',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const labelData: Array<ProjectLabel> = [
|
||||
{
|
||||
id: 'development',
|
||||
name: 'Development',
|
||||
createdDate: new Date().toString(),
|
||||
labelColor: {
|
||||
id: '1',
|
||||
colorHex: LabelColors.BLUE,
|
||||
name: 'blue',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const createCard = () => {
|
||||
const $ref = createRef<HTMLDivElement>();
|
||||
return (
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description="hello!"
|
||||
ref={$ref}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
watched
|
||||
labels={labelData}
|
||||
checklists={{ complete: 1, total: 4 }}
|
||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<List
|
||||
id=""
|
||||
name="General"
|
||||
isComposerOpen={false}
|
||||
onSaveName={action('on save name')}
|
||||
onOpenComposer={action('on open composer')}
|
||||
onExtraMenuOpen={action('extra menu open')}
|
||||
>
|
||||
<ListCards>
|
||||
<CardComposer
|
||||
onClose={() => {
|
||||
console.log('close!');
|
||||
}}
|
||||
onCreateCard={name => {
|
||||
console.log(name);
|
||||
}}
|
||||
isOpen={false}
|
||||
/>
|
||||
</ListCards>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithCardComposer = () => {
|
||||
return (
|
||||
<List
|
||||
id="1"
|
||||
name="General"
|
||||
isComposerOpen
|
||||
onSaveName={action('on save name')}
|
||||
onOpenComposer={action('on open composer')}
|
||||
onExtraMenuOpen={action('extra menu open')}
|
||||
>
|
||||
<ListCards>
|
||||
<CardComposer
|
||||
onClose={() => {
|
||||
console.log('close!');
|
||||
}}
|
||||
onCreateCard={name => {
|
||||
console.log(name);
|
||||
}}
|
||||
isOpen
|
||||
/>
|
||||
</ListCards>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithCard = () => {
|
||||
const $cardRef: any = createRef();
|
||||
return (
|
||||
<List
|
||||
id="1"
|
||||
name="General"
|
||||
isComposerOpen={false}
|
||||
onSaveName={action('on save name')}
|
||||
onOpenComposer={action('on open composer')}
|
||||
onExtraMenuOpen={action('extra menu open')}
|
||||
>
|
||||
<ListCards>
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description="hello!"
|
||||
ref={$cardRef}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
watched
|
||||
labels={labelData}
|
||||
checklists={{ complete: 1, total: 4 }}
|
||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||
/>
|
||||
<CardComposer
|
||||
onClose={() => {
|
||||
console.log('close!');
|
||||
}}
|
||||
onCreateCard={name => {
|
||||
console.log(name);
|
||||
}}
|
||||
isOpen={false}
|
||||
/>
|
||||
</ListCards>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
export const WithCardAndComposer = () => {
|
||||
const $cardRef: any = createRef();
|
||||
return (
|
||||
<List
|
||||
id="1"
|
||||
name="General"
|
||||
isComposerOpen
|
||||
onSaveName={action('on save name')}
|
||||
onOpenComposer={action('on open composer')}
|
||||
onExtraMenuOpen={action('extra menu open')}
|
||||
>
|
||||
<ListCards>
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description="hello!"
|
||||
ref={$cardRef}
|
||||
title="Hello, world"
|
||||
onClick={action('on click')}
|
||||
onContextMenu={action('on context click')}
|
||||
watched
|
||||
labels={labelData}
|
||||
checklists={{ complete: 1, total: 4 }}
|
||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||
/>
|
||||
<CardComposer
|
||||
onClose={() => {
|
||||
console.log('close!');
|
||||
}}
|
||||
onCreateCard={name => {
|
||||
console.log(name);
|
||||
}}
|
||||
isOpen
|
||||
/>
|
||||
</ListCards>
|
||||
</List>
|
||||
);
|
||||
};
|
128
frontend/src/shared/components/List/Styles.ts
Normal file
128
frontend/src/shared/components/List/Styles.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Container = styled.div`
|
||||
width: 272px;
|
||||
margin: 0 4px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const AddCardContainer = styled.div`
|
||||
min-height: 38px;
|
||||
max-height: 38px;
|
||||
display: ${props => (props.hidden ? 'none' : 'flex')};
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const AddCardButton = styled.a`
|
||||
border-radius: 3px;
|
||||
color: #c2c6dc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex: 1 0 auto;
|
||||
margin: 2px 8px 8px 8px;
|
||||
padding: 4px 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
&:hover {
|
||||
color: #c2c6dc;
|
||||
text-decoration: none;
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
export const Wrapper = styled.div`
|
||||
// background-color: #ebecf0;
|
||||
// background: rgb(244, 245, 247);
|
||||
background: #10163a;
|
||||
color: #c2c6dc;
|
||||
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
white-space: normal;
|
||||
`;
|
||||
|
||||
export const HeaderEditTarget = styled.div<{ isHidden: boolean }>`
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: ${props => (props.isHidden ? 'none' : 'block')};
|
||||
`;
|
||||
|
||||
export const HeaderName = styled(TextareaAutosize)`
|
||||
font-family: 'Droid Sans';
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
margin: -4px 0;
|
||||
padding: 4px 8px;
|
||||
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
text-transform: none;
|
||||
text-indent: 0px;
|
||||
text-shadow: none;
|
||||
flex-direction: column;
|
||||
text-align: start;
|
||||
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const Header = styled.div<{ isEditing: boolean }>`
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 8px;
|
||||
position: relative;
|
||||
min-height: 20px;
|
||||
padding-right: 36px;
|
||||
|
||||
${props =>
|
||||
props.isEditing &&
|
||||
css`
|
||||
& ${HeaderName} {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const AddCardButtonText = styled.span`
|
||||
padding-left: 5px;
|
||||
font-family: 'Droid Sans';
|
||||
`;
|
||||
|
||||
export const ListCards = styled.div`
|
||||
margin: 0 4px;
|
||||
padding: 0 4px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 45px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
`;
|
||||
|
||||
export const ListExtraMenuButtonWrapper = styled.div`
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
z-index: 1;
|
||||
padding: 6px;
|
||||
padding-bottom: 0;
|
||||
`;
|
125
frontend/src/shared/components/List/index.tsx
Normal file
125
frontend/src/shared/components/List/index.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
||||
import { Plus, Ellipsis } from 'shared/icons';
|
||||
|
||||
import {
|
||||
Container,
|
||||
Wrapper,
|
||||
Header,
|
||||
HeaderName,
|
||||
HeaderEditTarget,
|
||||
AddCardContainer,
|
||||
AddCardButton,
|
||||
AddCardButtonText,
|
||||
ListCards,
|
||||
ListExtraMenuButtonWrapper,
|
||||
} from './Styles';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
name: string;
|
||||
onSaveName: (name: string) => void;
|
||||
isComposerOpen: boolean;
|
||||
onOpenComposer: (id: string) => void;
|
||||
wrapperProps?: any;
|
||||
headerProps?: any;
|
||||
index?: number;
|
||||
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const List = React.forwardRef(
|
||||
(
|
||||
{
|
||||
id,
|
||||
name,
|
||||
onSaveName,
|
||||
isComposerOpen,
|
||||
onOpenComposer,
|
||||
children,
|
||||
wrapperProps,
|
||||
headerProps,
|
||||
onExtraMenuOpen,
|
||||
}: Props,
|
||||
$wrapperRef: any,
|
||||
) => {
|
||||
const [listName, setListName] = useState(name);
|
||||
const [isEditingTitle, setEditingTitle] = useState(false);
|
||||
const $listNameRef = useRef<HTMLTextAreaElement>(null);
|
||||
const $extraActionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onClick = () => {
|
||||
setEditingTitle(true);
|
||||
if ($listNameRef && $listNameRef.current) {
|
||||
$listNameRef.current.select();
|
||||
}
|
||||
};
|
||||
const onBlur = () => {
|
||||
setEditingTitle(false);
|
||||
onSaveName(listName);
|
||||
};
|
||||
const onEscape = () => {
|
||||
if ($listNameRef && $listNameRef.current) {
|
||||
$listNameRef.current.blur();
|
||||
}
|
||||
};
|
||||
const onChange = (event: React.FormEvent<HTMLTextAreaElement>): void => {
|
||||
setListName(event.currentTarget.value);
|
||||
};
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if ($listNameRef && $listNameRef.current) {
|
||||
$listNameRef.current.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtraMenuOpen = () => {
|
||||
if ($extraActionsRef && $extraActionsRef.current) {
|
||||
onExtraMenuOpen(id, $extraActionsRef);
|
||||
}
|
||||
};
|
||||
useOnEscapeKeyDown(isEditingTitle, onEscape);
|
||||
|
||||
return (
|
||||
<Container ref={$wrapperRef} {...wrapperProps}>
|
||||
<Wrapper>
|
||||
<Header {...headerProps} isEditing={isEditingTitle}>
|
||||
<HeaderEditTarget onClick={onClick} isHidden={isEditingTitle} />
|
||||
<HeaderName
|
||||
ref={$listNameRef}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
spellCheck={false}
|
||||
value={listName}
|
||||
/>
|
||||
<ListExtraMenuButtonWrapper ref={$extraActionsRef} onClick={handleExtraMenuOpen}>
|
||||
<Ellipsis size={16} color="#c2c6dc" />
|
||||
</ListExtraMenuButtonWrapper>
|
||||
</Header>
|
||||
{children && children}
|
||||
<AddCardContainer hidden={isComposerOpen}>
|
||||
<AddCardButton onClick={() => onOpenComposer(id)}>
|
||||
<Plus size={12} color="#c2c6dc" />
|
||||
<AddCardButtonText>Add another card</AddCardButtonText>
|
||||
</AddCardButton>
|
||||
</AddCardContainer>
|
||||
</Wrapper>
|
||||
</Container>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
List.defaultProps = {
|
||||
children: null,
|
||||
isComposerOpen: false,
|
||||
wrapperProps: {},
|
||||
headerProps: {},
|
||||
};
|
||||
|
||||
List.displayName = 'List';
|
||||
export default List;
|
||||
|
||||
export { ListCards };
|
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import ListActions from '.';
|
||||
|
||||
export default {
|
||||
component: ListActions,
|
||||
title: 'ListActions',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return <ListActions taskGroupID="1" onArchiveTaskGroup={action('on archive task group')} />;
|
||||
};
|
35
frontend/src/shared/components/ListActions/Styles.ts
Normal file
35
frontend/src/shared/components/ListActions/Styles.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const ListActionsWrapper = styled.ul`
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export const ListActionItemWrapper = styled.li`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
export const ListActionItem = styled.span`
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #c2c6dc;
|
||||
font-weight: 400;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
margin: 0 -12px;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ListSeparator = styled.hr`
|
||||
background-color: #414561;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
margin: 8px 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
`;
|
50
frontend/src/shared/components/ListActions/index.tsx
Normal file
50
frontend/src/shared/components/ListActions/index.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { ListActionsWrapper, ListActionItemWrapper, ListActionItem, ListSeparator } from './Styles';
|
||||
|
||||
type Props = {
|
||||
taskGroupID: string;
|
||||
|
||||
onArchiveTaskGroup: (taskGroupID: string) => void;
|
||||
};
|
||||
const LabelManager: React.FC<Props> = ({ taskGroupID, onArchiveTaskGroup }) => {
|
||||
return (
|
||||
<>
|
||||
<ListActionsWrapper>
|
||||
<ListActionItemWrapper>
|
||||
<ListActionItem>Add card...</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
<ListActionItemWrapper>
|
||||
<ListActionItem>Copy List...</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
<ListActionItemWrapper>
|
||||
<ListActionItem>Move card...</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
<ListActionItemWrapper>
|
||||
<ListActionItem>Watch</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
</ListActionsWrapper>
|
||||
<ListSeparator />
|
||||
<ListActionsWrapper>
|
||||
<ListActionItemWrapper>
|
||||
<ListActionItem>Sort By...</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
</ListActionsWrapper>
|
||||
<ListSeparator />
|
||||
<ListActionsWrapper>
|
||||
<ListActionItemWrapper>
|
||||
<ListActionItem>Move All Cards in This List...</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
<ListActionItemWrapper>
|
||||
<ListActionItem>Archive All Cards in This List...</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
</ListActionsWrapper>
|
||||
<ListSeparator />
|
||||
<ListActionsWrapper>
|
||||
<ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}>
|
||||
<ListActionItem>Archive This List</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
</ListActionsWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default LabelManager;
|
101
frontend/src/shared/components/Lists/Lists.stories.tsx
Normal file
101
frontend/src/shared/components/Lists/Lists.stories.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import Lists from '.';
|
||||
|
||||
export default {
|
||||
component: Lists,
|
||||
title: 'Lists',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#262c49', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const initialListsData = {
|
||||
columns: {
|
||||
'column-1': {
|
||||
taskGroupID: 'column-1',
|
||||
name: 'General',
|
||||
taskIds: ['task-3', 'task-4', 'task-1', 'task-2'],
|
||||
position: 1,
|
||||
tasks: [],
|
||||
},
|
||||
'column-2': {
|
||||
taskGroupID: 'column-2',
|
||||
name: 'Development',
|
||||
taskIds: [],
|
||||
position: 2,
|
||||
tasks: [],
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
'task-1': {
|
||||
taskID: 'task-1',
|
||||
taskGroup: { taskGroupID: 'column-1' },
|
||||
name: 'Create roadmap',
|
||||
position: 2,
|
||||
labels: [],
|
||||
},
|
||||
'task-2': {
|
||||
taskID: 'task-2',
|
||||
taskGroup: { taskGroupID: 'column-1' },
|
||||
position: 1,
|
||||
name: 'Create authentication',
|
||||
labels: [],
|
||||
},
|
||||
'task-3': {
|
||||
taskID: 'task-3',
|
||||
taskGroup: { taskGroupID: 'column-1' },
|
||||
position: 3,
|
||||
name: 'Create login',
|
||||
labels: [],
|
||||
},
|
||||
'task-4': {
|
||||
taskID: 'task-4',
|
||||
taskGroup: { taskGroupID: 'column-1' },
|
||||
position: 4,
|
||||
name: 'Create plugins',
|
||||
labels: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
const [listsData, setListsData] = useState(initialListsData);
|
||||
const onCardDrop = (droppedTask: Task) => {
|
||||
const newState = {
|
||||
...listsData,
|
||||
tasks: {
|
||||
...listsData.tasks,
|
||||
[droppedTask.id]: droppedTask,
|
||||
},
|
||||
};
|
||||
setListsData(newState);
|
||||
};
|
||||
const onListDrop = (droppedColumn: any) => {
|
||||
const newState = {
|
||||
...listsData,
|
||||
columns: {
|
||||
...listsData.columns,
|
||||
[droppedColumn.taskGroupID]: droppedColumn,
|
||||
},
|
||||
};
|
||||
setListsData(newState);
|
||||
};
|
||||
return (
|
||||
<Lists
|
||||
taskGroups={[]}
|
||||
onTaskClick={action('card click')}
|
||||
onQuickEditorOpen={action('card composer open')}
|
||||
onCreateTask={action('card create')}
|
||||
onTaskDrop={onCardDrop}
|
||||
onTaskGroupDrop={onListDrop}
|
||||
onChangeTaskGroupName={action('change group name')}
|
||||
onCreateTaskGroup={action('create list')}
|
||||
onExtraMenuOpen={action('extra menu open')}
|
||||
onCardMemberClick={action('card member click')}
|
||||
/>
|
||||
);
|
||||
};
|
48
frontend/src/shared/components/Lists/Styles.ts
Normal file
48
frontend/src/shared/components/Lists/Styles.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
flex: 1;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 8px;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #7367f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #10163a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const BoardContainer = styled.div`
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
export const BoardWrapper = styled.div`
|
||||
display: flex;
|
||||
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 8px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
`;
|
||||
export default Container;
|
228
frontend/src/shared/components/Lists/index.tsx
Normal file
228
frontend/src/shared/components/Lists/index.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
import List, { ListCards } from 'shared/components/List';
|
||||
import Card from 'shared/components/Card';
|
||||
import CardComposer from 'shared/components/CardComposer';
|
||||
import AddList from 'shared/components/AddList';
|
||||
import {
|
||||
isPositionChanged,
|
||||
getSortedDraggables,
|
||||
getNewDraggablePosition,
|
||||
getAfterDropDraggableList,
|
||||
} from 'shared/utils/draggables';
|
||||
import moment from 'moment';
|
||||
|
||||
import { Container, BoardContainer, BoardWrapper } from './Styles';
|
||||
|
||||
interface SimpleProps {
|
||||
taskGroups: Array<TaskGroup>;
|
||||
onTaskDrop: (task: Task, previousTaskGroupID: string) => void;
|
||||
onTaskGroupDrop: (taskGroup: TaskGroup) => void;
|
||||
|
||||
onTaskClick: (task: Task) => void;
|
||||
onCreateTask: (taskGroupID: string, name: string) => void;
|
||||
onChangeTaskGroupName: (taskGroupID: string, name: string) => void;
|
||||
onQuickEditorOpen: ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => void;
|
||||
onCreateTaskGroup: (listName: string) => void;
|
||||
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onCardMemberClick: OnCardMemberClick;
|
||||
}
|
||||
|
||||
const SimpleLists: React.FC<SimpleProps> = ({
|
||||
taskGroups,
|
||||
onTaskDrop,
|
||||
onChangeTaskGroupName,
|
||||
onTaskGroupDrop,
|
||||
onTaskClick,
|
||||
onCreateTask,
|
||||
onQuickEditorOpen,
|
||||
onCreateTaskGroup,
|
||||
onExtraMenuOpen,
|
||||
onCardMemberClick,
|
||||
}) => {
|
||||
const onDragEnd = ({ draggableId, source, destination, type }: DropResult) => {
|
||||
if (typeof destination === 'undefined') return;
|
||||
if (!isPositionChanged(source, destination)) return;
|
||||
|
||||
const isList = type === 'column';
|
||||
const isSameList = destination.droppableId === source.droppableId;
|
||||
let droppedDraggable: DraggableElement | null = null;
|
||||
let beforeDropDraggables: Array<DraggableElement> | null = null;
|
||||
|
||||
if (isList) {
|
||||
const droppedGroup = taskGroups.find(taskGroup => taskGroup.id === draggableId);
|
||||
if (droppedGroup) {
|
||||
droppedDraggable = {
|
||||
id: draggableId,
|
||||
position: droppedGroup.position,
|
||||
};
|
||||
beforeDropDraggables = getSortedDraggables(
|
||||
taskGroups.map(taskGroup => {
|
||||
return { id: taskGroup.id, position: taskGroup.position };
|
||||
}),
|
||||
);
|
||||
if (droppedDraggable === null || beforeDropDraggables === null) {
|
||||
throw new Error('before drop draggables is null');
|
||||
}
|
||||
const afterDropDraggables = getAfterDropDraggableList(
|
||||
beforeDropDraggables,
|
||||
droppedDraggable,
|
||||
isList,
|
||||
isSameList,
|
||||
destination,
|
||||
);
|
||||
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
|
||||
onTaskGroupDrop({
|
||||
...droppedGroup,
|
||||
position: newPosition,
|
||||
});
|
||||
} else {
|
||||
throw { error: 'task group can not be found' };
|
||||
}
|
||||
} else {
|
||||
const targetGroup = taskGroups.findIndex(
|
||||
taskGroup => taskGroup.tasks.findIndex(task => task.id === draggableId) !== -1,
|
||||
);
|
||||
const droppedTask = taskGroups[targetGroup].tasks.find(task => task.id === draggableId);
|
||||
|
||||
if (droppedTask) {
|
||||
droppedDraggable = {
|
||||
id: draggableId,
|
||||
position: droppedTask.position,
|
||||
};
|
||||
beforeDropDraggables = getSortedDraggables(
|
||||
taskGroups[targetGroup].tasks.map(task => {
|
||||
return { id: task.id, position: task.position };
|
||||
}),
|
||||
);
|
||||
if (droppedDraggable === null || beforeDropDraggables === null) {
|
||||
throw new Error('before drop draggables is null');
|
||||
}
|
||||
const afterDropDraggables = getAfterDropDraggableList(
|
||||
beforeDropDraggables,
|
||||
droppedDraggable,
|
||||
isList,
|
||||
isSameList,
|
||||
destination,
|
||||
);
|
||||
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
|
||||
const newTask = {
|
||||
...droppedTask,
|
||||
position: newPosition,
|
||||
taskGroup: {
|
||||
id: destination.droppableId,
|
||||
},
|
||||
};
|
||||
onTaskDrop(newTask, droppedTask.taskGroup.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [currentComposer, setCurrentComposer] = useState('');
|
||||
return (
|
||||
<BoardContainer>
|
||||
<BoardWrapper>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable direction="horizontal" type="column" droppableId="root">
|
||||
{provided => (
|
||||
<Container {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{taskGroups
|
||||
.slice()
|
||||
.sort((a: any, b: any) => a.position - b.position)
|
||||
.map((taskGroup: TaskGroup, index: number) => {
|
||||
return (
|
||||
<Draggable draggableId={taskGroup.id} key={taskGroup.id} index={index}>
|
||||
{columnDragProvided => (
|
||||
<Droppable type="tasks" droppableId={taskGroup.id}>
|
||||
{(columnDropProvided, snapshot) => (
|
||||
<List
|
||||
name={taskGroup.name}
|
||||
onOpenComposer={id => setCurrentComposer(id)}
|
||||
isComposerOpen={currentComposer === taskGroup.id}
|
||||
onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)}
|
||||
ref={columnDragProvided.innerRef}
|
||||
wrapperProps={columnDragProvided.draggableProps}
|
||||
headerProps={columnDragProvided.dragHandleProps}
|
||||
onExtraMenuOpen={onExtraMenuOpen}
|
||||
id={taskGroup.id}
|
||||
key={taskGroup.id}
|
||||
index={index}
|
||||
>
|
||||
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
|
||||
{taskGroup.tasks
|
||||
.slice()
|
||||
.sort((a: any, b: any) => a.position - b.position)
|
||||
.map((task: Task, taskIndex: any) => {
|
||||
return (
|
||||
<Draggable key={task.id} draggableId={task.id} index={taskIndex}>
|
||||
{taskProvided => {
|
||||
return (
|
||||
<Card
|
||||
wrapperProps={{
|
||||
...taskProvided.draggableProps,
|
||||
...taskProvided.dragHandleProps,
|
||||
}}
|
||||
ref={taskProvided.innerRef}
|
||||
taskID={task.id}
|
||||
complete={task.complete ?? false}
|
||||
taskGroupID={taskGroup.id}
|
||||
description=""
|
||||
labels={task.labels.map(label => label.projectLabel)}
|
||||
dueDate={
|
||||
task.dueDate
|
||||
? {
|
||||
isPastDue: false,
|
||||
formattedDate: moment(task.dueDate).format('MMM D, YYYY'),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={task.name}
|
||||
members={task.assigned}
|
||||
onClick={() => {
|
||||
onTaskClick(task);
|
||||
}}
|
||||
checklists={task.badges && task.badges.checklist}
|
||||
onCardMemberClick={onCardMemberClick}
|
||||
onContextMenu={onQuickEditorOpen}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
{columnDropProvided.placeholder}
|
||||
{currentComposer === taskGroup.id && (
|
||||
<CardComposer
|
||||
onClose={() => {
|
||||
setCurrentComposer('');
|
||||
}}
|
||||
onCreateCard={name => {
|
||||
onCreateTask(taskGroup.id, name);
|
||||
}}
|
||||
isOpen
|
||||
/>
|
||||
)}
|
||||
</ListCards>
|
||||
</List>
|
||||
)}
|
||||
</Droppable>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
<AddList
|
||||
onSave={listName => {
|
||||
onCreateTaskGroup(listName);
|
||||
}}
|
||||
/>
|
||||
{provided.placeholder}
|
||||
</Container>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</BoardWrapper>
|
||||
</BoardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleLists;
|
67
frontend/src/shared/components/Login/Login.stories.tsx
Normal file
67
frontend/src/shared/components/Login/Login.stories.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import styled from 'styled-components';
|
||||
import Login from '.';
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export default {
|
||||
component: Login,
|
||||
title: 'Login',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const LoginWrapper = styled.div`
|
||||
width: 60%;
|
||||
`;
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Container>
|
||||
<LoginWrapper>
|
||||
<Login onSubmit={action('on submit')} />
|
||||
</LoginWrapper>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithSubmission = () => {
|
||||
const onSubmit = async (data: LoginFormData, setComplete: (val: boolean) => void, setError: any) => {
|
||||
await sleep(2000);
|
||||
if (data.username !== 'test' || data.password !== 'test') {
|
||||
setError('username', 'invalid', 'Invalid username');
|
||||
setError('password', 'invalid', 'Invalid password');
|
||||
}
|
||||
setComplete(true);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Container>
|
||||
<LoginWrapper>
|
||||
<Login onSubmit={onSubmit} />
|
||||
</LoginWrapper>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
103
frontend/src/shared/components/Login/Styles.ts
Normal file
103
frontend/src/shared/components/Login/Styles.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
background: #eff2f7;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
export const Column = styled.div`
|
||||
width: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const LoginFormWrapper = styled.div`
|
||||
background: #10163a;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const LoginFormContainer = styled.div`
|
||||
min-height: 505px;
|
||||
padding: 2rem;
|
||||
`;
|
||||
|
||||
export const Title = styled.h1`
|
||||
color: #ebeefd;
|
||||
font-size: 18px;
|
||||
margin-bottom: 14px;
|
||||
`;
|
||||
|
||||
export const SubTitle = styled.h2`
|
||||
color: #c2c6dc;
|
||||
font-size: 14px;
|
||||
margin-bottom: 14px;
|
||||
`;
|
||||
export const Form = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const FormLabel = styled.label`
|
||||
color: #c2c6dc;
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
margin-top: 14px;
|
||||
`;
|
||||
|
||||
export const FormTextInput = styled.input`
|
||||
width: 100%;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
margin-top: 4px;
|
||||
padding: 0.7rem 1rem 0.7rem 3rem;
|
||||
font-size: 1rem;
|
||||
color: #c2c6dc;
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
export const FormIcon = styled.div`
|
||||
top: 30px;
|
||||
left: 16px;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export const FormError = styled.span`
|
||||
font-size: 0.875rem;
|
||||
color: rgb(234, 84, 85);
|
||||
`;
|
||||
|
||||
export const LoginButton = styled(Button)``;
|
||||
|
||||
export const ActionButtons = styled.div`
|
||||
margin-top: 17.5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const RegisterButton = styled(Button)``;
|
||||
|
||||
export const LogoTitle = styled.div`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-left: 12px;
|
||||
transition: visibility, opacity, transform 0.25s ease;
|
||||
color: #7367f0;
|
||||
`;
|
||||
|
||||
export const LogoWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 24px;
|
||||
color: rgb(222, 235, 255);
|
||||
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
|
||||
`;
|
88
frontend/src/shared/components/Login/index.tsx
Normal file
88
frontend/src/shared/components/Login/index.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import AccessAccount from 'shared/undraw/AccessAccount';
|
||||
import { User, Lock, Citadel } from 'shared/icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
Form,
|
||||
LogoWrapper,
|
||||
LogoTitle,
|
||||
ActionButtons,
|
||||
RegisterButton,
|
||||
LoginButton,
|
||||
FormError,
|
||||
FormIcon,
|
||||
FormLabel,
|
||||
FormTextInput,
|
||||
Wrapper,
|
||||
Column,
|
||||
LoginFormWrapper,
|
||||
LoginFormContainer,
|
||||
Title,
|
||||
SubTitle,
|
||||
} from './Styles';
|
||||
|
||||
const Login = ({ onSubmit }: LoginProps) => {
|
||||
const [isComplete, setComplete] = useState(true);
|
||||
const { register, handleSubmit, errors, setError, formState } = useForm<LoginFormData>();
|
||||
const loginSubmit = (data: LoginFormData) => {
|
||||
setComplete(false);
|
||||
onSubmit(data, setComplete, setError);
|
||||
};
|
||||
return (
|
||||
<Wrapper>
|
||||
<Column>
|
||||
<AccessAccount width={275} height={250} />
|
||||
</Column>
|
||||
<Column>
|
||||
<LoginFormWrapper>
|
||||
<LoginFormContainer>
|
||||
<LogoWrapper>
|
||||
<Citadel width={42} height={42} />
|
||||
<LogoTitle>Citadel</LogoTitle>
|
||||
</LogoWrapper>
|
||||
<Title>Login</Title>
|
||||
<SubTitle>Welcome back, please login into your account.</SubTitle>
|
||||
<Form onSubmit={handleSubmit(loginSubmit)}>
|
||||
<FormLabel htmlFor="username">
|
||||
Username
|
||||
<FormTextInput
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
ref={register({ required: 'Username is required' })}
|
||||
/>
|
||||
<FormIcon>
|
||||
<User color="#c2c6dc" size={20} />
|
||||
</FormIcon>
|
||||
</FormLabel>
|
||||
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||
<FormLabel htmlFor="password">
|
||||
Password
|
||||
<FormTextInput
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
ref={register({ required: 'Password is required' })}
|
||||
/>
|
||||
<FormIcon>
|
||||
<Lock width={20} height={20} />
|
||||
</FormIcon>
|
||||
</FormLabel>
|
||||
{errors.password && <FormError>{errors.password.message}</FormError>}
|
||||
|
||||
<ActionButtons>
|
||||
<RegisterButton variant="outline">Register</RegisterButton>
|
||||
<LoginButton type="submit" disabled={!isComplete}>
|
||||
Login
|
||||
</LoginButton>
|
||||
</ActionButtons>
|
||||
</Form>
|
||||
</LoginFormContainer>
|
||||
</LoginFormWrapper>
|
||||
</Column>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
71
frontend/src/shared/components/Member/index.tsx
Normal file
71
frontend/src/shared/components/Member/index.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const CardMember = styled.div<{ bgColor: string }>`
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
float: right;
|
||||
margin: 0 0 4px 4px;
|
||||
|
||||
background-color: ${props => props.bgColor};
|
||||
color: #fff;
|
||||
border-radius: 25em;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
z-index: 0;
|
||||
`;
|
||||
|
||||
const CardMemberInitials = styled.div`
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
type MemberProps = {
|
||||
onCardMemberClick?: OnCardMemberClick;
|
||||
taskID?: string;
|
||||
member: TaskUser;
|
||||
showName?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CardMemberWrapper = styled.div<{ ref: any }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const CardMemberName = styled.span`
|
||||
font-size: 16px;
|
||||
padding-left: 8px;
|
||||
`;
|
||||
|
||||
const Member: React.FC<MemberProps> = ({ onCardMemberClick, taskID, member, showName, className }) => {
|
||||
const $targetRef = useRef<HTMLDivElement>();
|
||||
return (
|
||||
<CardMemberWrapper
|
||||
key={member.id}
|
||||
ref={$targetRef}
|
||||
className={className}
|
||||
onClick={e => {
|
||||
if (onCardMemberClick) {
|
||||
e.stopPropagation();
|
||||
onCardMemberClick($targetRef, taskID ?? '', member.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardMember bgColor={member.profileIcon.bgColor ?? '#7367F0'}>
|
||||
<CardMemberInitials>{member.profileIcon.initials}</CardMemberInitials>
|
||||
</CardMember>
|
||||
{showName && <CardMemberName>{member.fullName}</CardMemberName>}
|
||||
</CardMemberWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Member;
|
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import MemberManager from '.';
|
||||
|
||||
export default {
|
||||
component: MemberManager,
|
||||
title: 'MemberManager',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return <MemberManager availableMembers={[]} activeMembers={[]} onMemberChange={action('member change')} />;
|
||||
};
|
92
frontend/src/shared/components/MemberManager/Styles.ts
Normal file
92
frontend/src/shared/components/MemberManager/Styles.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import styled from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea/lib';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const MemberManagerWrapper = styled.div``;
|
||||
|
||||
export const MemberManagerSearchWrapper = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const MemberManagerSearch = styled(TextareaAutosize)`
|
||||
margin: 4px 0 12px;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: 'Droid Sans';
|
||||
font-weight: 400;
|
||||
|
||||
background: #262c49;
|
||||
outline: none;
|
||||
color: #c2c6dc;
|
||||
border-color: #414561;
|
||||
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const BoardMembersLabel = styled.h4`
|
||||
color: #c2c6dc;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 16px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
export const BoardMembersList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
`;
|
||||
|
||||
export const BoardMembersListItem = styled.li``;
|
||||
|
||||
export const BoardMemberListItemContent = styled.div`
|
||||
background-color: rgba(9, 30, 66, 0.04);
|
||||
padding-right: 28px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
padding: 4px;
|
||||
margin-bottom: 2px;
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const ProfileIcon = styled.div`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #c2c6dc;
|
||||
font-weight: 700;
|
||||
background: rgb(115, 103, 240);
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
`;
|
||||
|
||||
export const MemberName = styled.span`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export const ActiveIconWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 11px;
|
||||
`;
|
73
frontend/src/shared/components/MemberManager/index.tsx
Normal file
73
frontend/src/shared/components/MemberManager/index.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
MemberName,
|
||||
ProfileIcon,
|
||||
MemberManagerWrapper,
|
||||
MemberManagerSearchWrapper,
|
||||
MemberManagerSearch,
|
||||
BoardMembersLabel,
|
||||
BoardMembersList,
|
||||
BoardMembersListItem,
|
||||
BoardMemberListItemContent,
|
||||
ActiveIconWrapper,
|
||||
} from './Styles';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
|
||||
type MemberManagerProps = {
|
||||
availableMembers: Array<TaskUser>;
|
||||
activeMembers: Array<TaskUser>;
|
||||
onMemberChange: (member: TaskUser, isActive: boolean) => void;
|
||||
};
|
||||
const MemberManager: React.FC<MemberManagerProps> = ({
|
||||
availableMembers,
|
||||
activeMembers: initialActiveMembers,
|
||||
onMemberChange,
|
||||
}) => {
|
||||
const [activeMembers, setActiveMembers] = useState(initialActiveMembers);
|
||||
const [currentSearch, setCurrentSearch] = useState('');
|
||||
return (
|
||||
<MemberManagerWrapper>
|
||||
<MemberManagerSearchWrapper>
|
||||
<MemberManagerSearch
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setCurrentSearch(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</MemberManagerSearchWrapper>
|
||||
<BoardMembersLabel>Board Members</BoardMembersLabel>
|
||||
<BoardMembersList>
|
||||
{availableMembers
|
||||
.filter(
|
||||
member => currentSearch === '' || member.fullName.toLowerCase().startsWith(currentSearch.toLowerCase()),
|
||||
)
|
||||
.map(member => {
|
||||
return (
|
||||
<BoardMembersListItem key={member.id}>
|
||||
<BoardMemberListItemContent
|
||||
onClick={() => {
|
||||
const isActive = activeMembers.findIndex(m => m.id === member.id) !== -1;
|
||||
if (isActive) {
|
||||
setActiveMembers(activeMembers.filter(m => m.id !== member.id));
|
||||
} else {
|
||||
setActiveMembers([...activeMembers, member]);
|
||||
}
|
||||
onMemberChange(member, !isActive);
|
||||
}}
|
||||
>
|
||||
<ProfileIcon>JK</ProfileIcon>
|
||||
<MemberName>{member.fullName}</MemberName>
|
||||
{activeMembers.findIndex(m => m.id === member.id) !== -1 && (
|
||||
<ActiveIconWrapper>
|
||||
<Checkmark width={16} height={16} />
|
||||
</ActiveIconWrapper>
|
||||
)}
|
||||
</BoardMemberListItemContent>
|
||||
</BoardMembersListItem>
|
||||
);
|
||||
})}
|
||||
</BoardMembersList>
|
||||
</MemberManagerWrapper>
|
||||
);
|
||||
};
|
||||
export default MemberManager;
|
132
frontend/src/shared/components/MiniProfile/Styles.ts
Normal file
132
frontend/src/shared/components/MiniProfile/Styles.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
|
||||
export const RoleCheckmark = styled(Checkmark)`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
export const Profile = styled.div`
|
||||
margin: 8px 0;
|
||||
min-height: 56px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const ProfileIcon = styled.div<{ bgUrl: string | null; bgColor: string }>`
|
||||
float: left;
|
||||
margin: 2px;
|
||||
background: ${props => (props.bgUrl ? `url(${props.bgUrl})` : props.bgColor)};
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
border-radius: 25em;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 50px;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export const ProfileInfo = styled.div`
|
||||
color: #c2c6dc;
|
||||
margin: 0 0 0 64px;
|
||||
word-wrap: break-word;
|
||||
`;
|
||||
|
||||
export const InfoTitle = styled.h3`
|
||||
margin: 0 40px 0 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const InfoUsername = styled.p`
|
||||
color: #c2c6dc;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
`;
|
||||
|
||||
export const InfoBio = styled.p`
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #c2c6dc;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export const MiniProfileActions = styled.ul`
|
||||
list-style-type: none;
|
||||
`;
|
||||
|
||||
export const MiniProfileActionWrapper = styled.li``;
|
||||
|
||||
export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
|
||||
color: #c2c6dc;
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
${props =>
|
||||
props.disabled
|
||||
? css`
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
color: rgba(${props.theme.colors.text.primary}, 0.4);
|
||||
`
|
||||
: css`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const CurrentPermission = styled.span`
|
||||
margin-left: 4px;
|
||||
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
height: 1px;
|
||||
border-top: 1px solid #414561;
|
||||
margin: 0.25rem !important;
|
||||
`;
|
||||
|
||||
export const WarningText = styled.span`
|
||||
display: flex;
|
||||
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
|
||||
padding: 6px;
|
||||
`;
|
||||
|
||||
export const DeleteDescription = styled.div`
|
||||
font-size: 14px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
||||
|
||||
export const RemoveMemberButton = styled(Button)`
|
||||
margin-top: 16px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const Content = styled.div`
|
||||
padding: 0 12px 12px;
|
||||
`;
|
||||
|
||||
export const RoleName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
export const RoleDescription = styled.div`
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
`;
|
218
frontend/src/shared/components/MiniProfile/index.tsx
Normal file
218
frontend/src/shared/components/MiniProfile/index.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import React from 'react';
|
||||
import { Popup, usePopup } from 'shared/components/PopupMenu';
|
||||
import { RoleCode } from 'shared/generated/graphql';
|
||||
|
||||
import {
|
||||
RoleCheckmark,
|
||||
RoleName,
|
||||
RoleDescription,
|
||||
Profile,
|
||||
Content,
|
||||
DeleteDescription,
|
||||
RemoveMemberButton,
|
||||
WarningText,
|
||||
ProfileIcon,
|
||||
Separator,
|
||||
ProfileInfo,
|
||||
InfoTitle,
|
||||
InfoUsername,
|
||||
InfoBio,
|
||||
CurrentPermission,
|
||||
MiniProfileActions,
|
||||
MiniProfileActionWrapper,
|
||||
MiniProfileActionItem,
|
||||
} from './Styles';
|
||||
|
||||
const permissions = [
|
||||
{
|
||||
code: 'owner',
|
||||
name: 'Owner',
|
||||
description:
|
||||
'Can view and edit cards, remove members, and change all settings for the project. Can delete the project.',
|
||||
},
|
||||
{
|
||||
code: 'admin',
|
||||
name: 'Admin',
|
||||
description: 'Can view and edit cards, remove members, and change all settings for the project.',
|
||||
},
|
||||
|
||||
{ code: 'member', name: 'Member', description: "Can view and edit cards. Can't change settings." },
|
||||
{
|
||||
code: 'observer',
|
||||
name: 'Observer',
|
||||
description: "Can view, comment, and vote on cards. Can't move or edit cards or change settings.",
|
||||
},
|
||||
];
|
||||
|
||||
type MiniProfileProps = {
|
||||
bio: string;
|
||||
user: TaskUser;
|
||||
onRemoveFromTask?: () => void;
|
||||
onChangeRole?: (roleCode: RoleCode) => void;
|
||||
onRemoveFromBoard?: () => void;
|
||||
onChangeProjectOwner?: (userID: string) => void;
|
||||
warning?: string | null;
|
||||
canChangeRole?: boolean;
|
||||
};
|
||||
const MiniProfile: React.FC<MiniProfileProps> = ({
|
||||
user,
|
||||
bio,
|
||||
canChangeRole,
|
||||
onChangeProjectOwner,
|
||||
onRemoveFromTask,
|
||||
onChangeRole,
|
||||
onRemoveFromBoard,
|
||||
warning,
|
||||
}) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} onClose={() => hidePopup()} tab={0}>
|
||||
<Profile>
|
||||
{user.profileIcon && (
|
||||
<ProfileIcon bgUrl={user.profileIcon.url ?? null} bgColor={user.profileIcon.bgColor ?? ''}>
|
||||
{user.profileIcon.url === null && user.profileIcon.initials}
|
||||
</ProfileIcon>
|
||||
)}
|
||||
<ProfileInfo>
|
||||
<InfoTitle>{user.fullName}</InfoTitle>
|
||||
<InfoUsername>{`@${user.username}`}</InfoUsername>
|
||||
<InfoBio>{bio}</InfoBio>
|
||||
</ProfileInfo>
|
||||
</Profile>
|
||||
<MiniProfileActions>
|
||||
<MiniProfileActionWrapper>
|
||||
{onRemoveFromTask && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
onRemoveFromTask();
|
||||
}}
|
||||
>
|
||||
Remove from card
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
{onChangeProjectOwner && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(3);
|
||||
}}
|
||||
>
|
||||
Set as project owner
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
{onChangeRole && user.role && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(1);
|
||||
}}
|
||||
>
|
||||
Change permissions...
|
||||
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
{onRemoveFromBoard && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(2);
|
||||
}}
|
||||
>
|
||||
Remove from board...
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
</MiniProfileActionWrapper>
|
||||
</MiniProfileActions>
|
||||
{warning && (
|
||||
<>
|
||||
<Separator />
|
||||
<WarningText>{warning}</WarningText>
|
||||
</>
|
||||
)}
|
||||
</Popup>
|
||||
|
||||
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
|
||||
<MiniProfileActions>
|
||||
<MiniProfileActionWrapper>
|
||||
{permissions
|
||||
.filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner')
|
||||
.map(perm => (
|
||||
<MiniProfileActionItem
|
||||
disabled={user.role && perm.code !== user.role.code && !canChangeRole}
|
||||
key={perm.code}
|
||||
onClick={() => {
|
||||
if (onChangeRole && user.role && perm.code !== user.role.code) {
|
||||
switch (perm.code) {
|
||||
case 'owner':
|
||||
onChangeRole(RoleCode.Owner);
|
||||
break;
|
||||
case 'admin':
|
||||
onChangeRole(RoleCode.Admin);
|
||||
break;
|
||||
case 'member':
|
||||
onChangeRole(RoleCode.Member);
|
||||
break;
|
||||
case 'observer':
|
||||
onChangeRole(RoleCode.Observer);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RoleName>
|
||||
{perm.name}
|
||||
{user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />}
|
||||
</RoleName>
|
||||
<RoleDescription>{perm.description}</RoleDescription>
|
||||
</MiniProfileActionItem>
|
||||
))}
|
||||
</MiniProfileActionWrapper>
|
||||
{user.role && user.role.code === 'owner' && (
|
||||
<>
|
||||
<Separator />
|
||||
<WarningText>You can't change roles because there must be an owner.</WarningText>
|
||||
</>
|
||||
)}
|
||||
</MiniProfileActions>
|
||||
</Popup>
|
||||
<Popup title="Remove Member?" onClose={() => hidePopup()} tab={2}>
|
||||
<Content>
|
||||
<DeleteDescription>
|
||||
The member will be removed from all cards on this project. They will receive a notification.
|
||||
</DeleteDescription>
|
||||
<RemoveMemberButton
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
if (onRemoveFromBoard) {
|
||||
onRemoveFromBoard();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove Member
|
||||
</RemoveMemberButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
<Popup title="Set as Project Owner?" onClose={() => hidePopup()} tab={3}>
|
||||
<Content>
|
||||
<DeleteDescription>
|
||||
This will change the project owner from you to this user. They will be able to view and edit cards, remove
|
||||
members, and change all settings for the project. They will also be able to delete the project.
|
||||
</DeleteDescription>
|
||||
<RemoveMemberButton
|
||||
color="warning"
|
||||
onClick={() => {
|
||||
if (onChangeProjectOwner) {
|
||||
onChangeProjectOwner(user.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Set as Project Owner
|
||||
</RemoveMemberButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniProfile;
|
32
frontend/src/shared/components/Modal/Modal.stories.tsx
Normal file
32
frontend/src/shared/components/Modal/Modal.stories.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import Modal from '.';
|
||||
|
||||
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>;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
32
frontend/src/shared/components/Modal/Styles.ts
Normal file
32
frontend/src/shared/components/Modal/Styles.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import styled from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const ScrollOverlay = styled.div`
|
||||
z-index: 3000;
|
||||
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(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const StyledModal = styled.div<{ width: number }>`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 48px 0 80px;
|
||||
width: 100%;
|
||||
background: #262c49;
|
||||
max-width: ${props => props.width}px;
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
${mixin.boxShadowMedium}
|
||||
`;
|
36
frontend/src/shared/components/Modal/index.tsx
Normal file
36
frontend/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')!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
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;
|
40
frontend/src/shared/components/Navbar/Navbar.stories.tsx
Normal file
40
frontend/src/shared/components/Navbar/Navbar.stories.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import { Home } from 'shared/icons';
|
||||
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from '.';
|
||||
|
||||
export default {
|
||||
component: Navbar,
|
||||
title: 'Navbar',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#cdd3e1' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 50px 80px;
|
||||
`;
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Navbar>
|
||||
<PrimaryLogo />
|
||||
<ButtonContainer>
|
||||
<ActionButton name="Home">
|
||||
<Home width={28} height={28} />
|
||||
</ActionButton>
|
||||
<ActionButton name="Home">
|
||||
<Home width={28} height={28} />
|
||||
</ActionButton>
|
||||
</ButtonContainer>
|
||||
</Navbar>
|
||||
</>
|
||||
);
|
||||
};
|
111
frontend/src/shared/components/Navbar/Styles.ts
Normal file
111
frontend/src/shared/components/Navbar/Styles.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const Logo = styled.div``;
|
||||
|
||||
export const LogoTitle = styled.div`
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
transition: visibility, opacity, transform 0.25s ease;
|
||||
color: #7367f0;
|
||||
`;
|
||||
export const ActionContainer = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const ActionButtonTitle = styled.span`
|
||||
position: relative;
|
||||
visibility: hidden;
|
||||
left: -5px;
|
||||
opacity: 0;
|
||||
font-weight: 600;
|
||||
transition: left 0.1s ease 0s, visibility, opacity, transform 0.25s ease;
|
||||
|
||||
font-size: 18px;
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
export const IconWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.25s ease;
|
||||
`;
|
||||
|
||||
export const ActionButtonContainer = styled.div`
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
|
||||
& > a:first-child > div {
|
||||
padding-top: 48px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionButtonWrapper = styled.div<{ active?: boolean }>`
|
||||
${props =>
|
||||
props.active &&
|
||||
css`
|
||||
background: rgb(115, 103, 240);
|
||||
box-shadow: 0 0 10px 1px rgba(115, 103, 240, 0.7);
|
||||
`}
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 24px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&:hover ${ActionButtonTitle} {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
&:hover ${IconWrapper} {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
`;
|
||||
|
||||
export const LogoWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
color: rgb(222, 235, 255);
|
||||
cursor: pointer;
|
||||
transition: color 0.1s ease 0s, border 0.1s ease 0s;
|
||||
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
|
||||
`;
|
||||
|
||||
export const Container = styled.aside`
|
||||
z-index: 100;
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
overflow-x: hidden;
|
||||
height: 100vh;
|
||||
width: 80px;
|
||||
transform: translateZ(0px);
|
||||
background: #10163a;
|
||||
transition: all 0.1s ease 0s;
|
||||
border-right: 1px solid rgba(65, 69, 97, 0.65);
|
||||
|
||||
&:hover {
|
||||
width: 260px;
|
||||
box-shadow: rgba(0, 0, 0, 0.6) 0px 0px 50px 0px;
|
||||
border-right: 1px solid rgba(65, 69, 97, 0);
|
||||
}
|
||||
&:hover ${LogoTitle} {
|
||||
bottom: -12px;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover ${ActionButtonTitle} {
|
||||
left: 15px;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover ${LogoWrapper} {
|
||||
border-bottom: 1px solid rgba(65, 69, 97, 0);
|
||||
}
|
||||
`;
|
48
frontend/src/shared/components/Navbar/index.tsx
Normal file
48
frontend/src/shared/components/Navbar/index.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Citadel } from 'shared/icons';
|
||||
import {
|
||||
Container,
|
||||
LogoWrapper,
|
||||
IconWrapper,
|
||||
Logo,
|
||||
LogoTitle,
|
||||
ActionContainer,
|
||||
ActionButtonContainer,
|
||||
ActionButtonWrapper,
|
||||
ActionButtonTitle,
|
||||
} from './Styles';
|
||||
|
||||
type ActionButtonProps = {
|
||||
name: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export const ActionButton: React.FC<ActionButtonProps> = ({ name, active, children }) => {
|
||||
return (
|
||||
<ActionButtonWrapper active={active ?? false}>
|
||||
<IconWrapper>{children}</IconWrapper>
|
||||
<ActionButtonTitle>{name}</ActionButtonTitle>
|
||||
</ActionButtonWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const ButtonContainer: React.FC = ({ children }) => (
|
||||
<ActionContainer>
|
||||
<ActionButtonContainer>{children}</ActionButtonContainer>
|
||||
</ActionContainer>
|
||||
);
|
||||
|
||||
export const PrimaryLogo = () => {
|
||||
return (
|
||||
<LogoWrapper>
|
||||
<Citadel width={42} height={42} />
|
||||
<LogoTitle>Citadel</LogoTitle>
|
||||
</LogoWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const Navbar: React.FC = ({ children }) => {
|
||||
return <Container>{children}</Container>;
|
||||
};
|
||||
|
||||
export default Navbar;
|
@ -0,0 +1,33 @@
|
||||
import React, { useState, useRef, createRef } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import styled from 'styled-components';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
|
||||
import NewProject from '.';
|
||||
|
||||
export default {
|
||||
component: NewProject,
|
||||
title: 'NewProject',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<NewProject
|
||||
initialTeamID={null}
|
||||
onCreateProject={action('create project')}
|
||||
teams={[{ name: 'General', id: 'general', createdAt: '' }]}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
281
frontend/src/shared/components/NewProject/index.tsx
Normal file
281
frontend/src/shared/components/NewProject/index.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Select from 'react-select';
|
||||
import { ArrowLeft, Cross } from 'shared/icons';
|
||||
|
||||
const Overlay = styled.div`
|
||||
z-index: 10000;
|
||||
background: #262c49;
|
||||
bottom: 0;
|
||||
color: #fff;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`;
|
||||
const Header = styled.div`
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
transition: box-shadow 250ms;
|
||||
`;
|
||||
|
||||
const HeaderLeft = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
`;
|
||||
const HeaderRight = styled.div`
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 32px 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const ContainerContent = styled.div`
|
||||
width: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #c2c6dc;
|
||||
margin-bottom: 25px;
|
||||
`;
|
||||
|
||||
const ProjectName = styled.input`
|
||||
margin: 0 0 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #262c49;
|
||||
outline: none;
|
||||
color: #c2c6dc;
|
||||
|
||||
border-radius: 3px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
border-color: #414561;
|
||||
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
|
||||
&:focus {
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
}
|
||||
`;
|
||||
const ProjectNameLabel = styled.label`
|
||||
color: #c2c6dc;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
const ProjectInfo = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const ProjectField = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 15px;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
const ProjectTeamField = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const colourStyles = {
|
||||
control: (styles: any, data: any) => {
|
||||
return {
|
||||
...styles,
|
||||
backgroundColor: data.isMenuOpen ? mixin.darken('#262c49', 0.15) : '#262c49',
|
||||
boxShadow: data.menuIsOpen ? 'rgb(115, 103, 240) 0px 0px 0px 1px' : 'none',
|
||||
borderRadius: '3px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderImage: 'initial',
|
||||
borderColor: '#414561',
|
||||
':hover': {
|
||||
boxShadow: 'rgb(115, 103, 240) 0px 0px 0px 1px',
|
||||
borderRadius: '3px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderImage: 'initial',
|
||||
borderColor: '#414561',
|
||||
},
|
||||
':active': {
|
||||
boxShadow: 'rgb(115, 103, 240) 0px 0px 0px 1px',
|
||||
borderRadius: '3px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderImage: 'initial',
|
||||
borderColor: 'rgb(115, 103, 240)',
|
||||
},
|
||||
};
|
||||
},
|
||||
menu: (styles: any) => {
|
||||
return {
|
||||
...styles,
|
||||
backgroundColor: mixin.darken('#262c49', 0.15),
|
||||
};
|
||||
},
|
||||
dropdownIndicator: (styles: any) => ({ ...styles, color: '#c2c6dc', ':hover': { color: '#c2c6dc' } }),
|
||||
indicatorSeparator: (styles: any) => ({ ...styles, color: '#c2c6dc' }),
|
||||
option: (styles: any, { data, isDisabled, isFocused, isSelected }: any) => {
|
||||
return {
|
||||
...styles,
|
||||
backgroundColor: isDisabled
|
||||
? null
|
||||
: isSelected
|
||||
? mixin.darken('#262c49', 0.25)
|
||||
: isFocused
|
||||
? mixin.darken('#262c49', 0.15)
|
||||
: null,
|
||||
color: isDisabled ? '#ccc' : isSelected ? '#fff' : '#c2c6dc',
|
||||
cursor: isDisabled ? 'not-allowed' : 'default',
|
||||
':active': {
|
||||
...styles[':active'],
|
||||
backgroundColor: !isDisabled && (isSelected ? mixin.darken('#262c49', 0.25) : '#fff'),
|
||||
},
|
||||
':hover': {
|
||||
...styles[':hover'],
|
||||
backgroundColor: !isDisabled && (isSelected ? 'rgb(115, 103, 240)' : 'rgb(115, 103, 240)'),
|
||||
},
|
||||
};
|
||||
},
|
||||
placeholder: (styles: any) => ({ ...styles, color: '#c2c6dc' }),
|
||||
clearIndicator: (styles: any) => ({ ...styles, color: '#c2c6dc', ':hover': { color: '#c2c6dc' } }),
|
||||
input: (styles: any) => ({
|
||||
...styles,
|
||||
color: '#fff',
|
||||
}),
|
||||
singleValue: (styles: any) => {
|
||||
return {
|
||||
...styles,
|
||||
color: '#fff',
|
||||
};
|
||||
},
|
||||
};
|
||||
const CreateButton = styled.button`
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
line-height: 20px;
|
||||
padding: 6px 12px;
|
||||
background-color: none;
|
||||
text-align: center;
|
||||
color: #c2c6dc;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
border-radius: 3px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
border-color: #414561;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: rgb(115, 103, 240);
|
||||
border-color: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
type NewProjectProps = {
|
||||
initialTeamID: string | null;
|
||||
teams: Array<Team>;
|
||||
onClose: () => void;
|
||||
onCreateProject: (projectName: string, teamID: string) => void;
|
||||
};
|
||||
|
||||
const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose, onCreateProject }) => {
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [team, setTeam] = useState<null | string>(initialTeamID);
|
||||
const options = teams.map(t => ({ label: t.name, value: t.id }));
|
||||
return (
|
||||
<Overlay>
|
||||
<Content>
|
||||
<Header>
|
||||
<HeaderLeft
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ArrowLeft color="#c2c6dc" />
|
||||
</HeaderLeft>
|
||||
<HeaderRight
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Cross width={16} height={16} />
|
||||
</HeaderRight>
|
||||
</Header>
|
||||
<Container>
|
||||
<ContainerContent>
|
||||
<Title>Add project details</Title>
|
||||
<ProjectInfo>
|
||||
<ProjectField>
|
||||
<ProjectNameLabel>Project name</ProjectNameLabel>
|
||||
<ProjectName
|
||||
value={projectName}
|
||||
onChange={(e: any) => {
|
||||
setProjectName(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</ProjectField>
|
||||
<ProjectTeamField>
|
||||
<ProjectNameLabel>Team</ProjectNameLabel>
|
||||
<Select
|
||||
onChange={(e: any) => {
|
||||
setTeam(e.value);
|
||||
}}
|
||||
value={options.filter(d => d.value === team)}
|
||||
styles={colourStyles}
|
||||
classNamePrefix="teamSelect"
|
||||
options={options}
|
||||
/>
|
||||
</ProjectTeamField>
|
||||
</ProjectInfo>
|
||||
<CreateButton
|
||||
onClick={() => {
|
||||
if (team && projectName !== '') {
|
||||
onCreateProject(projectName, team);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create project
|
||||
</CreateButton>
|
||||
</ContainerContent>
|
||||
</Container>
|
||||
</Content>
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewProject;
|
80
frontend/src/shared/components/PopupMenu/LabelEditor.tsx
Normal file
80
frontend/src/shared/components/PopupMenu/LabelEditor.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const WhiteCheckmark = styled(Checkmark)`
|
||||
fill: rgba(${props => props.theme.colors.text.secondary});
|
||||
`;
|
||||
type Props = {
|
||||
labelColors: Array<LabelColor>;
|
||||
label: ProjectLabel | null;
|
||||
onLabelEdit: (labelId: string | null, labelName: string, labelColor: LabelColor) => void;
|
||||
onLabelDelete?: (labelId: string) => void;
|
||||
};
|
||||
|
||||
const LabelManager = ({ labelColors, label, onLabelEdit, onLabelDelete }: Props) => {
|
||||
const $fieldName = useRef<HTMLInputElement>(null);
|
||||
const [currentLabel, setCurrentLabel] = useState(label ? label.name : '');
|
||||
const [currentColor, setCurrentColor] = useState<LabelColor | null>(label ? label.labelColor : null);
|
||||
|
||||
useEffect(() => {
|
||||
if ($fieldName.current) {
|
||||
$fieldName.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EditLabelForm>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldName
|
||||
ref={$fieldName}
|
||||
id="labelName"
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={e => {
|
||||
setCurrentLabel(e.currentTarget.value);
|
||||
}}
|
||||
value={currentLabel ?? ''}
|
||||
/>
|
||||
<FieldLabel>Select a color</FieldLabel>
|
||||
<div>
|
||||
{labelColors.map((labelColor: LabelColor) => (
|
||||
<LabelBox
|
||||
key={labelColor.id}
|
||||
color={labelColor.colorHex}
|
||||
onClick={() => {
|
||||
setCurrentColor(labelColor);
|
||||
}}
|
||||
>
|
||||
{currentColor && labelColor.id === currentColor.id && <WhiteCheckmark width={12} height={12} />}
|
||||
</LabelBox>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<SaveButton
|
||||
value="Save"
|
||||
type="submit"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
console.log(currentColor);
|
||||
if (currentColor) {
|
||||
onLabelEdit(label ? label.id : null, currentLabel ?? '', currentColor);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{label && onLabelDelete && (
|
||||
<DeleteButton
|
||||
value="Delete"
|
||||
type="submit"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onLabelDelete(label.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EditLabelForm>
|
||||
);
|
||||
};
|
||||
export default LabelManager;
|
93
frontend/src/shared/components/PopupMenu/LabelManager.tsx
Normal file
93
frontend/src/shared/components/PopupMenu/LabelManager.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Pencil, Checkmark } from 'shared/icons';
|
||||
|
||||
import {
|
||||
LabelSearch,
|
||||
ActiveIcon,
|
||||
Labels,
|
||||
Label,
|
||||
CardLabel,
|
||||
Section,
|
||||
SectionTitle,
|
||||
LabelIcon,
|
||||
CreateLabelButton,
|
||||
} from './Styles';
|
||||
|
||||
type Props = {
|
||||
labels?: Array<ProjectLabel>;
|
||||
taskLabels?: Array<TaskLabel>;
|
||||
onLabelToggle: (labelId: string) => void;
|
||||
onLabelEdit: (labelId: string) => void;
|
||||
onLabelCreate: () => void;
|
||||
};
|
||||
|
||||
const LabelManager: React.FC<Props> = ({ labels, taskLabels, onLabelToggle, onLabelEdit, onLabelCreate }) => {
|
||||
const $fieldName = useRef<HTMLInputElement>(null);
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const [currentSearch, setCurrentSearch] = useState('');
|
||||
useEffect(() => {
|
||||
if ($fieldName.current) {
|
||||
$fieldName.current.focus();
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<LabelSearch
|
||||
type="text"
|
||||
ref={$fieldName}
|
||||
placeholder="search labels..."
|
||||
onChange={e => {
|
||||
setCurrentSearch(e.currentTarget.value);
|
||||
}}
|
||||
value={currentSearch}
|
||||
/>
|
||||
<Section>
|
||||
<SectionTitle>Labels</SectionTitle>
|
||||
<Labels>
|
||||
{labels &&
|
||||
labels
|
||||
.filter(
|
||||
label =>
|
||||
currentSearch === '' ||
|
||||
(label.name && label.name.toLowerCase().startsWith(currentSearch.toLowerCase())),
|
||||
)
|
||||
.map(label => (
|
||||
<Label key={label.id}>
|
||||
<LabelIcon
|
||||
onClick={() => {
|
||||
onLabelEdit(label.id);
|
||||
}}
|
||||
>
|
||||
<Pencil width={16} height={16} />
|
||||
</LabelIcon>
|
||||
<CardLabel
|
||||
key={label.id}
|
||||
color={label.labelColor.colorHex}
|
||||
active={currentLabel === label.id}
|
||||
onMouseEnter={() => {
|
||||
setCurrentLabel(label.id);
|
||||
}}
|
||||
onClick={() => onLabelToggle(label.id)}
|
||||
>
|
||||
{label.name}
|
||||
{taskLabels && taskLabels.find(t => t.projectLabel.id === label.id) && (
|
||||
<ActiveIcon>
|
||||
<Checkmark width={16} height={16} />
|
||||
</ActiveIcon>
|
||||
)}
|
||||
</CardLabel>
|
||||
</Label>
|
||||
))}
|
||||
</Labels>
|
||||
<CreateLabelButton
|
||||
onClick={() => {
|
||||
onLabelCreate();
|
||||
}}
|
||||
>
|
||||
Create a new label
|
||||
</CreateLabelButton>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default LabelManager;
|
382
frontend/src/shared/components/PopupMenu/PopupMenu.stories.tsx
Normal file
382
frontend/src/shared/components/PopupMenu/PopupMenu.stories.tsx
Normal file
@ -0,0 +1,382 @@
|
||||
import React, { useState, useRef, createRef } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import LabelColors from 'shared/constants/labelColors';
|
||||
import LabelManager from 'shared/components/PopupMenu/LabelManager';
|
||||
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
|
||||
import ListActions from 'shared/components/ListActions';
|
||||
import MemberManager from 'shared/components/MemberManager';
|
||||
import DueDateManager from 'shared/components/DueDateManager';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import produce from 'immer';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
|
||||
import PopupMenu, { PopupProvider, usePopup, Popup } from '.';
|
||||
|
||||
export default {
|
||||
component: PopupMenu,
|
||||
title: 'PopupMenu',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const labelData: Array<ProjectLabel> = [
|
||||
{
|
||||
id: 'development',
|
||||
name: 'Development',
|
||||
createdDate: new Date().toString(),
|
||||
labelColor: {
|
||||
id: '1',
|
||||
colorHex: LabelColors.BLUE,
|
||||
name: 'blue',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const OpenLabelBtn = styled.span``;
|
||||
|
||||
type TabProps = {
|
||||
tab: number;
|
||||
};
|
||||
|
||||
const LabelManagerEditor = () => {
|
||||
const [labels, setLabels] = useState(labelData);
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const { setTab } = usePopup();
|
||||
return (
|
||||
<>
|
||||
<Popup title="Labels" tab={0} onClose={action('on close')}>
|
||||
<LabelManager
|
||||
labels={labels}
|
||||
onLabelCreate={() => {
|
||||
setTab(2);
|
||||
}}
|
||||
onLabelEdit={labelId => {
|
||||
setCurrentLabel(labelId);
|
||||
setTab(1);
|
||||
}}
|
||||
onLabelToggle={labelId => {
|
||||
setLabels(
|
||||
produce(labels, draftState => {
|
||||
const idx = labels.findIndex(label => label.id === labelId);
|
||||
if (idx !== -1) {
|
||||
draftState[idx] = { ...draftState[idx] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup onClose={action('on close')} title="Edit label" tab={1}>
|
||||
<LabelEditor
|
||||
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
|
||||
label={labels.find(label => label.id === currentLabel) ?? null}
|
||||
onLabelEdit={(_labelId, name, color) => {
|
||||
setLabels(
|
||||
produce(labels, draftState => {
|
||||
const idx = labels.findIndex(label => label.id === currentLabel);
|
||||
if (idx !== -1) {
|
||||
draftState[idx] = {
|
||||
...draftState[idx],
|
||||
name,
|
||||
labelColor: {
|
||||
...draftState[idx].labelColor,
|
||||
name: color.name ?? '',
|
||||
colorHex: color.colorHex,
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
setTab(0);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup onClose={action('on close')} title="Create new label" tab={2}>
|
||||
<LabelEditor
|
||||
label={null}
|
||||
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
|
||||
onLabelEdit={(_labelId, name, color) => {
|
||||
setLabels([
|
||||
...labels,
|
||||
{
|
||||
id: name,
|
||||
name,
|
||||
createdDate: new Date().toString(),
|
||||
labelColor: {
|
||||
id: color.id,
|
||||
colorHex: color.colorHex,
|
||||
name: color.name ?? '',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
setTab(0);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OpenLabelsButton = () => {
|
||||
const $buttonRef = createRef<HTMLButtonElement>();
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const [labels, setLabels] = useState(labelData);
|
||||
const { showPopup, setTab } = usePopup();
|
||||
console.log(labels);
|
||||
return (
|
||||
<OpenLabelBtn
|
||||
ref={$buttonRef}
|
||||
onClick={() => {
|
||||
showPopup($buttonRef, <LabelManagerEditor />);
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</OpenLabelBtn>
|
||||
);
|
||||
};
|
||||
|
||||
export const LabelsPopup = () => {
|
||||
const [isPopupOpen, setPopupOpen] = useState(false);
|
||||
return (
|
||||
<PopupProvider>
|
||||
<OpenLabelsButton />
|
||||
</PopupProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const LabelsLabelEditor = () => {
|
||||
const [isPopupOpen, setPopupOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
{isPopupOpen && (
|
||||
<PopupMenu
|
||||
onPrevious={action('on previous')}
|
||||
title="Change Label"
|
||||
top={10}
|
||||
onClose={() => setPopupOpen(false)}
|
||||
left={10}
|
||||
>
|
||||
<LabelEditor
|
||||
label={labelData[0]}
|
||||
onLabelEdit={action('label edit')}
|
||||
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
|
||||
/>
|
||||
</PopupMenu>
|
||||
)}
|
||||
<button type="submit" onClick={() => setPopupOpen(true)}>
|
||||
Open
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const initalState = { left: 0, top: 0, isOpen: false };
|
||||
|
||||
export const ListActionsPopup = () => {
|
||||
const $buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popupData, setPopupData] = useState(initalState);
|
||||
return (
|
||||
<>
|
||||
{popupData.isOpen && (
|
||||
<PopupMenu
|
||||
title="List Actions"
|
||||
top={popupData.top}
|
||||
onClose={() => setPopupData(initalState)}
|
||||
left={popupData.left}
|
||||
>
|
||||
<ListActions taskGroupID="1" onArchiveTaskGroup={action('archive task group')} />
|
||||
</PopupMenu>
|
||||
)}
|
||||
<button
|
||||
ref={$buttonRef}
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
if ($buttonRef && $buttonRef.current) {
|
||||
const pos = $buttonRef.current.getBoundingClientRect();
|
||||
setPopupData({
|
||||
isOpen: true,
|
||||
left: pos.left,
|
||||
top: pos.top + pos.height + 10,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MemberManagerPopup = () => {
|
||||
const $buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popupData, setPopupData] = useState(initalState);
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
{popupData.isOpen && (
|
||||
<PopupMenu title="Members" top={popupData.top} onClose={() => setPopupData(initalState)} left={popupData.left}>
|
||||
<MemberManager
|
||||
availableMembers={[
|
||||
{
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
},
|
||||
]}
|
||||
activeMembers={[]}
|
||||
onMemberChange={action('member change')}
|
||||
/>
|
||||
</PopupMenu>
|
||||
)}
|
||||
<span
|
||||
ref={$buttonRef}
|
||||
onClick={() => {
|
||||
if ($buttonRef && $buttonRef.current) {
|
||||
const pos = $buttonRef.current.getBoundingClientRect();
|
||||
setPopupData({
|
||||
isOpen: true,
|
||||
left: pos.left,
|
||||
top: pos.top + pos.height + 10,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DueDateManagerPopup = () => {
|
||||
const $buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popupData, setPopupData] = useState(initalState);
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
{popupData.isOpen && (
|
||||
<PopupMenu title="Due Date" top={popupData.top} onClose={() => setPopupData(initalState)} left={popupData.left}>
|
||||
<DueDateManager
|
||||
onRemoveDueDate={action('remove due date')}
|
||||
task={{
|
||||
id: '1',
|
||||
taskGroup: { name: 'General', id: '1', position: 1 },
|
||||
name: 'Hello, world',
|
||||
position: 1,
|
||||
labels: [
|
||||
{
|
||||
id: 'soft-skills',
|
||||
assignedDate: new Date().toString(),
|
||||
projectLabel: {
|
||||
createdDate: new Date().toString(),
|
||||
id: 'label-soft-skills',
|
||||
name: 'Soft Skills',
|
||||
labelColor: {
|
||||
id: '1',
|
||||
name: 'white',
|
||||
colorHex: '#fff',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
description: 'hello!',
|
||||
assigned: [
|
||||
{
|
||||
id: '1',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
fullName: 'Jordan Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCancel={action('cancel')}
|
||||
onDueDateChange={action('due date change')}
|
||||
/>
|
||||
</PopupMenu>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
width: '60px',
|
||||
textAlign: 'center',
|
||||
margin: '25px auto',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
ref={$buttonRef}
|
||||
onClick={() => {
|
||||
if ($buttonRef && $buttonRef.current) {
|
||||
const pos = $buttonRef.current.getBoundingClientRect();
|
||||
setPopupData({
|
||||
isOpen: true,
|
||||
left: pos.left,
|
||||
top: pos.top + pos.height + 10,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MiniProfilePopup = () => {
|
||||
const $buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popupData, setPopupData] = useState(initalState);
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
{popupData.isOpen && (
|
||||
<PopupMenu
|
||||
noHeader
|
||||
title="Due Date"
|
||||
top={popupData.top}
|
||||
onClose={() => setPopupData(initalState)}
|
||||
left={popupData.left}
|
||||
>
|
||||
<MiniProfile
|
||||
user={{
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
username: 'jordanthedev',
|
||||
profileIcon: { url: null, bgColor: '#000', initials: 'JK' },
|
||||
}}
|
||||
bio="Stuff and things"
|
||||
onRemoveFromTask={action('mini profile')}
|
||||
/>
|
||||
</PopupMenu>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
width: '60px',
|
||||
textAlign: 'center',
|
||||
margin: '25px auto',
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
background: '#f00',
|
||||
}}
|
||||
ref={$buttonRef}
|
||||
onClick={() => {
|
||||
if ($buttonRef && $buttonRef.current) {
|
||||
const pos = $buttonRef.current.getBoundingClientRect();
|
||||
setPopupData({
|
||||
isOpen: true,
|
||||
left: pos.left,
|
||||
top: pos.top + pos.height + 10,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
374
frontend/src/shared/components/PopupMenu/Styles.ts
Normal file
374
frontend/src/shared/components/PopupMenu/Styles.ts
Normal file
@ -0,0 +1,374 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Container = styled.div<{
|
||||
invertY: boolean;
|
||||
invert: boolean;
|
||||
top: number;
|
||||
left: number;
|
||||
ref: any;
|
||||
width: number | string;
|
||||
}>`
|
||||
left: ${props => props.left}px;
|
||||
top: ${props => props.top}px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: ${props => props.width}px;
|
||||
padding-top: 10px;
|
||||
height: auto;
|
||||
z-index: 40000;
|
||||
${props =>
|
||||
props.invert &&
|
||||
css`
|
||||
transform: translate(-100%);
|
||||
`}
|
||||
${props =>
|
||||
props.invertY &&
|
||||
css`
|
||||
top: auto;
|
||||
padding-top: 0;
|
||||
padding-bottom: 10px;
|
||||
bottom: ${props.top}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
margin: 0;
|
||||
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const Header = styled.div`
|
||||
height: 40px;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const HeaderTitle = styled.span`
|
||||
box-sizing: border-box;
|
||||
color: #c2c6dc;
|
||||
display: block;
|
||||
border-bottom: 1px solid #414561;
|
||||
margin: 0 12px;
|
||||
overflow: hidden;
|
||||
padding: 0 32px;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
|
||||
height: 40px;
|
||||
line-height: 18px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const Content = styled.div`
|
||||
max-height: 632px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
export const LabelSearch = styled.input`
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
transition-property: background-color, border-color, box-shadow;
|
||||
transition-duration: 85ms;
|
||||
transition-timing-function: ease;
|
||||
margin: 4px 0 12px;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: 'Droid Sans';
|
||||
font-weight: 400;
|
||||
|
||||
background: #262c49;
|
||||
outline: none;
|
||||
color: #c2c6dc;
|
||||
border-color: #414561;
|
||||
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Section = styled.div`
|
||||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
export const SectionTitle = styled.h4`
|
||||
color: #c2c6dc;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 16px;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
export const Labels = styled.ul`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
export const Label = styled.li`
|
||||
padding-right: 36px;
|
||||
position: relative;
|
||||
`;
|
||||
export const CardLabel = styled.span<{ active: boolean; color: string }>`
|
||||
${props =>
|
||||
props.active &&
|
||||
css`
|
||||
margin-left: 4px;
|
||||
box-shadow: -8px 0 ${mixin.darken(props.color, 0.12)};
|
||||
border-radius: 3px;
|
||||
`}
|
||||
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
min-height: 20px;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
transition: padding 85ms, margin 85ms, box-shadow 85ms;
|
||||
background-color: ${props => props.color};
|
||||
color: #fff;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-height: 31px;
|
||||
`;
|
||||
|
||||
export const CloseButton = styled.div`
|
||||
padding: 18px 18px 14px 12px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 40;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const LabelIcon = styled.div`
|
||||
border-radius: 3px;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActiveIcon = styled.div`
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
opacity: 0.85;
|
||||
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
export const EditLabelForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const FieldLabel = styled.label`
|
||||
font-weight: 700;
|
||||
color: #5e6c84;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export const FieldName = styled.input`
|
||||
margin: 4px 0 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #262c49;
|
||||
outline: none;
|
||||
color: #c2c6dc;
|
||||
|
||||
border-radius: 3px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
border-color: #414561;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const LabelBox = styled.span<{ color: string }>`
|
||||
float: left;
|
||||
height: 32px;
|
||||
margin: 0 8px 8px 0;
|
||||
padding: 0;
|
||||
width: 48px;
|
||||
|
||||
cursor: pointer;
|
||||
background-color: ${props => props.color};
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SaveButton = styled.input`
|
||||
background: rgb(115, 103, 240);
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-right: 4px;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
export const DeleteButton = styled.input`
|
||||
float: right;
|
||||
outline: none;
|
||||
border: none;
|
||||
line-height: 20px;
|
||||
padding: 6px 12px;
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
color: #c2c6dc;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
margin: 0 0 0 8px;
|
||||
|
||||
border-radius: 3px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
border-color: #414561;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: rgb(115, 103, 240);
|
||||
border-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CreateLabelButton = styled.button`
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 12px;
|
||||
background-color: none;
|
||||
text-align: center;
|
||||
color: #c2c6dc;
|
||||
margin: 8px 4px 0 0;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const PreviousButton = styled.div`
|
||||
padding: 18px 18px 14px 12px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 40;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const ContainerDiamond = styled.div<{ invert: boolean; invertY: boolean }>`
|
||||
${props => (props.invert ? 'right: 10px; ' : 'left: 15px;')}
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
${props =>
|
||||
props.invertY
|
||||
? css`
|
||||
bottom: 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
`
|
||||
: css`
|
||||
top: 10px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||
`}
|
||||
transform: rotate(45deg) translate(-7px);
|
||||
z-index: 10;
|
||||
|
||||
background: #262c49;
|
||||
border-color: #414561;
|
||||
`;
|
258
frontend/src/shared/components/PopupMenu/index.tsx
Normal file
258
frontend/src/shared/components/PopupMenu/index.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import React, { useRef, createContext, RefObject, useState, useContext, useEffect } from 'react';
|
||||
import { Cross, AngleLeft } from 'shared/icons';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { createPortal } from 'react-dom';
|
||||
import produce from 'immer';
|
||||
import {
|
||||
Container,
|
||||
ContainerDiamond,
|
||||
Header,
|
||||
HeaderTitle,
|
||||
Content,
|
||||
CloseButton,
|
||||
PreviousButton,
|
||||
Wrapper,
|
||||
} from './Styles';
|
||||
|
||||
type PopupContextState = {
|
||||
show: (target: RefObject<HTMLElement>, content: JSX.Element, width?: string | number) => void;
|
||||
setTab: (newTab: number, width?: number | string) => void;
|
||||
getCurrentTab: () => number;
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
type PopupProps = {
|
||||
title: string | null;
|
||||
onClose?: () => void;
|
||||
tab: number;
|
||||
};
|
||||
|
||||
type PopupContainerProps = {
|
||||
top: number;
|
||||
left: number;
|
||||
invert: boolean;
|
||||
invertY: boolean;
|
||||
onClose: () => void;
|
||||
width?: string | number;
|
||||
};
|
||||
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({ width, top, left, onClose, children, invert, invertY }) => {
|
||||
const $containerRef = useRef<HTMLDivElement>(null);
|
||||
const [currentTop, setCurrentTop] = useState(top);
|
||||
useOnOutsideClick($containerRef, true, onClose, null);
|
||||
return (
|
||||
<Container width={width ?? 316} left={left} top={currentTop} ref={$containerRef} invert={invert} invertY={invertY}>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
PopupContainer.defaultProps = {
|
||||
width: 316,
|
||||
};
|
||||
|
||||
const PopupContext = createContext<PopupContextState>({
|
||||
show: () => {},
|
||||
setTab: () => {},
|
||||
getCurrentTab: () => 0,
|
||||
hide: () => {},
|
||||
});
|
||||
|
||||
export const usePopup = () => {
|
||||
const ctx = useContext<PopupContextState>(PopupContext);
|
||||
return { showPopup: ctx.show, setTab: ctx.setTab, getCurrentTab: ctx.getCurrentTab, hidePopup: ctx.hide };
|
||||
};
|
||||
|
||||
type PopupState = {
|
||||
isOpen: boolean;
|
||||
left: number;
|
||||
top: number;
|
||||
invertY: boolean;
|
||||
invert: boolean;
|
||||
currentTab: number;
|
||||
previousTab: number;
|
||||
content: JSX.Element | null;
|
||||
width?: string | number;
|
||||
};
|
||||
|
||||
const { Provider, Consumer } = PopupContext;
|
||||
|
||||
const canUseDOM = !!(typeof window !== 'undefined' && window.document && window.document.createElement);
|
||||
|
||||
const defaultState = {
|
||||
isOpen: false,
|
||||
left: 0,
|
||||
top: 0,
|
||||
invert: false,
|
||||
invertY: false,
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content: null,
|
||||
};
|
||||
|
||||
export const PopupProvider: React.FC = ({ children }) => {
|
||||
const [currentState, setState] = useState<PopupState>(defaultState);
|
||||
const show = (target: RefObject<HTMLElement>, content: JSX.Element, width?: number | string) => {
|
||||
if (target && target.current) {
|
||||
const bounds = target.current.getBoundingClientRect();
|
||||
let top = bounds.top + bounds.height;
|
||||
let invertY = false;
|
||||
if (window.innerHeight / 2 < top) {
|
||||
top = window.innerHeight - bounds.top;
|
||||
invertY = true;
|
||||
}
|
||||
if (bounds.left + 304 + 30 > window.innerWidth) {
|
||||
setState({
|
||||
isOpen: true,
|
||||
left: bounds.left + bounds.width,
|
||||
top,
|
||||
invertY,
|
||||
invert: true,
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content,
|
||||
width: width ?? 316,
|
||||
});
|
||||
} else {
|
||||
setState({
|
||||
isOpen: true,
|
||||
left: bounds.left,
|
||||
top,
|
||||
invert: false,
|
||||
invertY,
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content,
|
||||
width: width ?? 316,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const hide = () => {
|
||||
setState({
|
||||
isOpen: false,
|
||||
left: 0,
|
||||
top: 0,
|
||||
invert: true,
|
||||
invertY: false,
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content: null,
|
||||
});
|
||||
};
|
||||
const portalTarget = canUseDOM ? document.body : null; // appease flow
|
||||
|
||||
const setTab = (newTab: number, width?: number | string) => {
|
||||
const newWidth = width ?? currentState.width;
|
||||
setState((prevState: PopupState) => {
|
||||
return {
|
||||
...prevState,
|
||||
previousTab: currentState.currentTab,
|
||||
currentTab: newTab,
|
||||
width: newWidth,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentTab = () => {
|
||||
return currentState.currentTab;
|
||||
};
|
||||
|
||||
return (
|
||||
<Provider value={{ hide, show, setTab, getCurrentTab }}>
|
||||
{portalTarget &&
|
||||
currentState.isOpen &&
|
||||
createPortal(
|
||||
<PopupContainer
|
||||
invertY={currentState.invertY}
|
||||
invert={currentState.invert}
|
||||
top={currentState.top}
|
||||
left={currentState.left}
|
||||
onClose={() => setState(defaultState)}
|
||||
width={currentState.width ?? 316}
|
||||
>
|
||||
{currentState.content}
|
||||
<ContainerDiamond invertY={currentState.invertY} invert={currentState.invert} />
|
||||
</PopupContainer>,
|
||||
portalTarget,
|
||||
)}
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string | null;
|
||||
top: number;
|
||||
left: number;
|
||||
onClose: () => void;
|
||||
onPrevious?: () => void | null;
|
||||
noHeader?: boolean | null;
|
||||
width?: string | number;
|
||||
};
|
||||
|
||||
const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader, children, onPrevious }) => {
|
||||
const $containerRef = useRef<HTMLDivElement>(null);
|
||||
useOnOutsideClick($containerRef, true, onClose, null);
|
||||
|
||||
return (
|
||||
<Container invertY={false} width={width ?? 316} invert={false} left={left} top={top} ref={$containerRef}>
|
||||
<Wrapper>
|
||||
{onPrevious && (
|
||||
<PreviousButton onClick={onPrevious}>
|
||||
<AngleLeft color="#c2c6dc" />
|
||||
</PreviousButton>
|
||||
)}
|
||||
{noHeader ? (
|
||||
<CloseButton onClick={() => onClose()}>
|
||||
<Cross width={16} height={16} />
|
||||
</CloseButton>
|
||||
) : (
|
||||
<Header>
|
||||
<HeaderTitle>{title}</HeaderTitle>
|
||||
<CloseButton onClick={() => onClose()}>
|
||||
<Cross width={16} height={16} />
|
||||
</CloseButton>
|
||||
</Header>
|
||||
)}
|
||||
<Content>{children}</Content>
|
||||
</Wrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const Popup: React.FC<PopupProps> = ({ title, onClose, tab, children }) => {
|
||||
const { getCurrentTab, setTab } = usePopup();
|
||||
if (getCurrentTab() !== tab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper>
|
||||
{tab > 0 && (
|
||||
<PreviousButton
|
||||
onClick={() => {
|
||||
setTab(0);
|
||||
}}
|
||||
>
|
||||
<AngleLeft color="#c2c6dc" />
|
||||
</PreviousButton>
|
||||
)}
|
||||
{title && (
|
||||
<Header>
|
||||
<HeaderTitle>{title}</HeaderTitle>
|
||||
</Header>
|
||||
)}
|
||||
{onClose && (
|
||||
<CloseButton onClick={() => onClose()}>
|
||||
<Cross width={16} height={16} />
|
||||
</CloseButton>
|
||||
)}
|
||||
<Content>{children}</Content>
|
||||
</Wrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupMenu;
|
45
frontend/src/shared/components/ProfileIcon/index.tsx
Normal file
45
frontend/src/shared/components/ProfileIcon/index.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>`
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
`;
|
||||
|
||||
type ProfileIconProps = {
|
||||
user: TaskUser;
|
||||
onProfileClick: ($target: React.RefObject<HTMLElement>, user: TaskUser) => void;
|
||||
size: number | string;
|
||||
};
|
||||
|
||||
const ProfileIcon: React.FC<ProfileIconProps> = ({ user, onProfileClick, size }) => {
|
||||
const $profileRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Container
|
||||
ref={$profileRef}
|
||||
onClick={() => {
|
||||
onProfileClick($profileRef, user);
|
||||
}}
|
||||
size={size}
|
||||
backgroundURL={user.profileIcon.url ?? null}
|
||||
bgColor={user.profileIcon.bgColor ?? null}
|
||||
>
|
||||
{(!user.profileIcon.url && user.profileIcon.initials) ?? ''}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileIcon.defaultProps = {
|
||||
size: 28,
|
||||
};
|
||||
|
||||
export default ProfileIcon;
|
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import ProjectGridItem from '.';
|
||||
|
||||
export default {
|
||||
component: ProjectGridItem,
|
||||
title: 'ProjectGridItem',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const projectsData = [
|
||||
{ taskGroups: [], teamTitle: 'Personal', projectID: 'aaaa', name: 'Citadel', color: '#aa62e3' },
|
||||
{ taskGroups: [], teamTitle: 'Personal', projectID: 'bbbb', name: 'Editorial Calender', color: '#aa62e3' },
|
||||
{ taskGroups: [], teamTitle: 'Personal', projectID: 'cccc', name: 'New Blog', color: '#aa62e3' },
|
||||
];
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const ProjectsWrapper = styled.div`
|
||||
width: 60%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<Container>
|
||||
<ProjectsWrapper>
|
||||
{projectsData.map(project => (
|
||||
<ProjectGridItem project={project} />
|
||||
))}
|
||||
</ProjectsWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
65
frontend/src/shared/components/ProjectGridItem/Styles.ts
Normal file
65
frontend/src/shared/components/ProjectGridItem/Styles.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import styled from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const AddProjectLabel = styled.span`
|
||||
padding-top: 4px;
|
||||
font-size: 14px;
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const ProjectContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ProjectTitle = styled.span`
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
transition: transform 0.25s ease;
|
||||
`;
|
||||
export const TeamTitle = styled.span`
|
||||
margin-top: 5px;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const ProjectWrapper = styled.div<{ color: string }>`
|
||||
display: flex;
|
||||
padding: 15px 25px; border-radius: 20px;
|
||||
${mixin.boxShadowCard}
|
||||
background: ${props => mixin.darken(props.color, 0.35)};
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
margin: 0 10px;
|
||||
width: 240px;
|
||||
height: 100px;
|
||||
transition: transform 0.25s ease;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
`;
|
||||
|
||||
export const AddProjectWrapper = styled.div`
|
||||
display: flex;
|
||||
padding: 15px 25px;
|
||||
border-radius: 20px;
|
||||
${mixin.boxShadowCard}
|
||||
border: 1px dashed;
|
||||
border-color: #c2c6dc;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
margin: 0 10px;
|
||||
width: 240px;
|
||||
flex-direction: column;
|
||||
height: 100px;
|
||||
transition: transform 0.25s ease;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
`;
|
37
frontend/src/shared/components/ProjectGridItem/index.tsx
Normal file
37
frontend/src/shared/components/ProjectGridItem/index.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Plus } from 'shared/icons';
|
||||
import { AddProjectWrapper, AddProjectLabel, ProjectWrapper, ProjectContent, ProjectTitle, TeamTitle } from './Styles';
|
||||
|
||||
type AddProjectItemProps = {
|
||||
onAddProject: () => void;
|
||||
};
|
||||
export const AddProjectItem: React.FC<AddProjectItemProps> = ({ onAddProject }) => {
|
||||
return (
|
||||
<AddProjectWrapper
|
||||
onClick={() => {
|
||||
onAddProject();
|
||||
}}
|
||||
>
|
||||
<Plus size={20} color="#c2c6dc" />
|
||||
<AddProjectLabel>New Project</AddProjectLabel>
|
||||
</AddProjectWrapper>
|
||||
);
|
||||
};
|
||||
type Props = {
|
||||
project: Project;
|
||||
};
|
||||
|
||||
const ProjectsList = ({ project }: Props) => {
|
||||
const color = project.color ?? '#c2c6dc';
|
||||
return (
|
||||
<ProjectWrapper color={color}>
|
||||
<ProjectContent>
|
||||
<ProjectTitle>{project.name}</ProjectTitle>
|
||||
<TeamTitle>{project.teamTitle}</TeamTitle>
|
||||
</ProjectContent>
|
||||
</ProjectWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsList;
|
130
frontend/src/shared/components/ProjectSettings/index.tsx
Normal file
130
frontend/src/shared/components/ProjectSettings/index.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
export const ListActionsWrapper = styled.ul`
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export const ListActionItemWrapper = styled.li`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
export const ListActionItem = styled.span`
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #c2c6dc;
|
||||
font-weight: 400;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
margin: 0 -12px;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ListSeparator = styled.hr`
|
||||
background-color: #414561;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
margin: 8px 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
onDeleteProject: () => void;
|
||||
};
|
||||
const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
|
||||
return (
|
||||
<>
|
||||
<ListActionsWrapper>
|
||||
<ListActionItemWrapper onClick={() => onDeleteProject()}>
|
||||
<ListActionItem>Delete Project</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
</ListActionsWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TeamSettingsProps = {
|
||||
onDeleteTeam: () => void;
|
||||
};
|
||||
export const TeamSettings: React.FC<TeamSettingsProps> = ({ onDeleteTeam }) => {
|
||||
return (
|
||||
<>
|
||||
<ListActionsWrapper>
|
||||
<ListActionItemWrapper onClick={() => onDeleteTeam()}>
|
||||
<ListActionItem>Delete Team</ListActionItem>
|
||||
</ListActionItemWrapper>
|
||||
</ListActionsWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmWrapper = styled.div``;
|
||||
|
||||
const ConfirmSubTitle = styled.h3`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const ConfirmDescription = styled.div`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const DeleteList = styled.ul`
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
const DeleteListItem = styled.li`
|
||||
padding: 6px 0;
|
||||
list-style: disc;
|
||||
margin-left: 12px;
|
||||
`;
|
||||
|
||||
const ConfirmDeleteButton = styled(Button)`
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
type DeleteConfirmProps = {
|
||||
description: string;
|
||||
deletedItems: Array<string>;
|
||||
onConfirmDelete: () => void;
|
||||
};
|
||||
|
||||
export const DELETE_INFO = {
|
||||
DELETE_PROJECTS: {
|
||||
description: 'Deleting the project will also delete the following:',
|
||||
deletedItems: ['Task groups and tasks'],
|
||||
},
|
||||
DELETE_TEAMS: {
|
||||
description: 'Deleting the team will also delete the following:',
|
||||
deletedItems: ['Projects under the team', 'All task groups & tasks associated with the team projects'],
|
||||
},
|
||||
};
|
||||
|
||||
const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems, onConfirmDelete }) => {
|
||||
return (
|
||||
<ConfirmWrapper>
|
||||
<ConfirmDescription>
|
||||
{description}
|
||||
<DeleteList>
|
||||
{deletedItems.map(item => (
|
||||
<DeleteListItem>{item}</DeleteListItem>
|
||||
))}
|
||||
</DeleteList>
|
||||
</ConfirmDescription>
|
||||
<ConfirmDeleteButton onClick={() => onConfirmDelete()} color="danger">
|
||||
Delete
|
||||
</ConfirmDeleteButton>
|
||||
</ConfirmWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export { DeleteConfirm };
|
||||
export default ProjectSettings;
|
@ -0,0 +1,106 @@
|
||||
import React, { createRef, useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import Card from 'shared/components/Card';
|
||||
import CardComposer from 'shared/components/CardComposer';
|
||||
import LabelColors from 'shared/constants/labelColors';
|
||||
import List, { ListCards } from 'shared/components/List';
|
||||
import QuickCardEditor from 'shared/components/QuickCardEditor';
|
||||
|
||||
export default {
|
||||
component: QuickCardEditor,
|
||||
title: 'QuickCardEditor',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const labelData: Array<TaskLabel> = [
|
||||
{
|
||||
id: 'development',
|
||||
assignedDate: new Date().toString(),
|
||||
projectLabel: {
|
||||
id: 'development',
|
||||
name: 'Development',
|
||||
createdDate: 'date',
|
||||
labelColor: {
|
||||
id: 'label-color-blue',
|
||||
colorHex: LabelColors.BLUE,
|
||||
name: 'blue',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const Default = () => {
|
||||
const $cardRef: any = createRef();
|
||||
const task: Task = {
|
||||
id: 'task',
|
||||
name: 'Hello, world!',
|
||||
position: 1,
|
||||
labels: labelData,
|
||||
taskGroup: {
|
||||
id: '1',
|
||||
},
|
||||
};
|
||||
const [isEditorOpen, setEditorOpen] = useState(false);
|
||||
const [target, setTarget] = useState<null | React.RefObject<HTMLElement>>(null);
|
||||
const [top, setTop] = useState(0);
|
||||
const [left, setLeft] = useState(0);
|
||||
return (
|
||||
<>
|
||||
{isEditorOpen && target && (
|
||||
<QuickCardEditor
|
||||
task={task}
|
||||
onCloseEditor={() => setEditorOpen(false)}
|
||||
onEditCard={action('edit card')}
|
||||
onOpenLabelsPopup={action('open popup')}
|
||||
onOpenDueDatePopup={action('open popup')}
|
||||
onOpenMembersPopup={action('open popup')}
|
||||
onToggleComplete={action('complete')}
|
||||
onArchiveCard={action('archive card')}
|
||||
target={target}
|
||||
/>
|
||||
)}
|
||||
<List
|
||||
id="1"
|
||||
name="General"
|
||||
isComposerOpen={false}
|
||||
onSaveName={action('on save name')}
|
||||
onOpenComposer={action('on open composer')}
|
||||
onExtraMenuOpen={(taskGroupID, $targetRef) => console.log(taskGroupID, $targetRef)}
|
||||
>
|
||||
<ListCards>
|
||||
<Card
|
||||
taskID="1"
|
||||
taskGroupID="1"
|
||||
description="hello!"
|
||||
ref={$cardRef}
|
||||
title={task.name}
|
||||
onClick={action('on click')}
|
||||
onContextMenu={e => {
|
||||
setTarget($cardRef);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
watched
|
||||
labels={labelData.map(l => l.projectLabel)}
|
||||
checklists={{ complete: 1, total: 4 }}
|
||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||
/>
|
||||
<CardComposer
|
||||
onClose={() => {
|
||||
console.log('close!');
|
||||
}}
|
||||
onCreateCard={name => {
|
||||
console.log(name);
|
||||
}}
|
||||
isOpen={false}
|
||||
/>
|
||||
</ListCards>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
93
frontend/src/shared/components/QuickCardEditor/Styles.ts
Normal file
93
frontend/src/shared/components/QuickCardEditor/Styles.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import styled, { keyframes, css } from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Wrapper = styled.div<{ open: boolean }>`
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
bottom: 0;
|
||||
color: #fff;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
visibility: ${props => (props.open ? 'show' : 'hidden')};
|
||||
`;
|
||||
|
||||
export const Container = styled.div<{ fixed: boolean; width: number; top: number; left: number }>`
|
||||
position: absolute;
|
||||
width: ${props => props.width}px;
|
||||
top: ${props => props.top}px;
|
||||
left: ${props => props.left}px;
|
||||
|
||||
${props =>
|
||||
props.fixed &&
|
||||
css`
|
||||
top: auto;
|
||||
bottom: ${props.top}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const SaveButton = styled.button`
|
||||
cursor: pointer;
|
||||
background: rgb(115, 103, 240);
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-top: 8px;
|
||||
margin-right: 4px;
|
||||
padding: 6px 24px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
export const FadeInAnimation = keyframes`
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
`;
|
||||
|
||||
export const EditorButtons = styled.div<{ fixed: boolean }>`
|
||||
left: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 240px;
|
||||
z-index: 0;
|
||||
animation: ${FadeInAnimation} 85ms ease-in 1;
|
||||
${props =>
|
||||
props.fixed &&
|
||||
css`
|
||||
top: auto;
|
||||
bottom: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const EditorButton = styled.div`
|
||||
cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 3px;
|
||||
clear: both;
|
||||
color: #c2c6dc;
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 0 0 4px 8px;
|
||||
padding: 6px 12px 6px 8px;
|
||||
text-decoration: none;
|
||||
transition: transform 85ms ease-in;
|
||||
`;
|
||||
|
||||
export const CloseButton = styled.div`
|
||||
padding: 9px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
opacity: 0.8;
|
||||
z-index: 40;
|
||||
cursor: pointer;
|
||||
`;
|
138
frontend/src/shared/components/QuickCardEditor/index.tsx
Normal file
138
frontend/src/shared/components/QuickCardEditor/index.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Cross from 'shared/icons/Cross';
|
||||
import styled from 'styled-components';
|
||||
import { Wrapper, Container, EditorButtons, SaveButton, EditorButton, CloseButton } from './Styles';
|
||||
import Card from '../Card';
|
||||
|
||||
export const CardMembers = styled.div`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
task: Task;
|
||||
onCloseEditor: () => void;
|
||||
onEditCard: (taskGroupID: string, taskID: string, cardName: string) => void;
|
||||
onToggleComplete: (task: Task) => void;
|
||||
onOpenLabelsPopup: ($targetRef: React.RefObject<HTMLElement>, task: Task) => void;
|
||||
onOpenMembersPopup: ($targetRef: React.RefObject<HTMLElement>, task: Task) => void;
|
||||
onOpenDueDatePopup: ($targetRef: React.RefObject<HTMLElement>, task: Task) => void;
|
||||
onArchiveCard: (taskGroupID: string, taskID: string) => void;
|
||||
onCardMemberClick?: OnCardMemberClick;
|
||||
target: React.RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
const QuickCardEditor = ({
|
||||
task,
|
||||
onCloseEditor,
|
||||
onOpenLabelsPopup,
|
||||
onOpenMembersPopup,
|
||||
onOpenDueDatePopup,
|
||||
onToggleComplete,
|
||||
onCardMemberClick,
|
||||
onArchiveCard,
|
||||
onEditCard,
|
||||
target: $target,
|
||||
}: Props) => {
|
||||
const [currentCardTitle, setCardTitle] = useState(task.name);
|
||||
const $labelsRef: any = useRef();
|
||||
const $dueDate: any = useRef();
|
||||
const $membersRef: any = useRef();
|
||||
|
||||
const handleCloseEditor = (e: any) => {
|
||||
e.stopPropagation();
|
||||
onCloseEditor();
|
||||
};
|
||||
|
||||
const height = 180;
|
||||
const saveCardButtonBarHeight = 48;
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
let width = 272;
|
||||
let fixed = false;
|
||||
if ($target && $target.current) {
|
||||
const pos = $target.current.getBoundingClientRect();
|
||||
top = pos.top;
|
||||
left = pos.left;
|
||||
width = pos.width;
|
||||
const isFixed = window.innerHeight / 2 < pos.top;
|
||||
if (isFixed) {
|
||||
top = window.innerHeight - pos.bottom - saveCardButtonBarHeight;
|
||||
fixed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper onClick={handleCloseEditor} open>
|
||||
<CloseButton onClick={handleCloseEditor}>
|
||||
<Cross width={16} height={16} />
|
||||
</CloseButton>
|
||||
<Container fixed={fixed} width={width} left={left} top={top}>
|
||||
<Card
|
||||
editable
|
||||
onCardMemberClick={onCardMemberClick}
|
||||
title={currentCardTitle}
|
||||
onEditCard={(taskGroupID, taskID, name) => {
|
||||
onEditCard(taskGroupID, taskID, name);
|
||||
onCloseEditor();
|
||||
}}
|
||||
complete={task.complete ?? false}
|
||||
members={task.assigned}
|
||||
taskID={task.id}
|
||||
taskGroupID={task.taskGroup.id}
|
||||
labels={task.labels.map(l => l.projectLabel)}
|
||||
/>
|
||||
<SaveButton onClick={() => onEditCard(task.taskGroup.id, task.id, currentCardTitle)}>Save</SaveButton>
|
||||
<EditorButtons fixed={fixed}>
|
||||
<EditorButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onToggleComplete(task);
|
||||
}}
|
||||
>
|
||||
{task.complete ? 'Mark Incomplete' : 'Mark Complete'}
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
ref={$membersRef}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenMembersPopup($membersRef, task);
|
||||
}}
|
||||
>
|
||||
Edit Assigned
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
ref={$dueDate}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenDueDatePopup($labelsRef, task);
|
||||
}}
|
||||
>
|
||||
Edit Due Date
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
ref={$labelsRef}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onOpenLabelsPopup($labelsRef, task);
|
||||
}}
|
||||
>
|
||||
Edit Labels
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onArchiveCard(task.taskGroup.id, task.id);
|
||||
onCloseEditor();
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
</EditorButton>
|
||||
</EditorButtons>
|
||||
</Container>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickCardEditor;
|
126
frontend/src/shared/components/Select/index.tsx
Normal file
126
frontend/src/shared/components/Select/index.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import Select from 'react-select';
|
||||
import styled from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
const colourStyles = {
|
||||
control: (styles: any, data: any) => {
|
||||
return {
|
||||
...styles,
|
||||
backgroundColor: data.isMenuOpen ? mixin.darken('#262c49', 0.15) : '#262c49',
|
||||
boxShadow: data.menuIsOpen ? 'rgb(115, 103, 240) 0px 0px 0px 1px' : 'none',
|
||||
borderRadius: '3px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderImage: 'initial',
|
||||
borderColor: '#414561',
|
||||
':hover': {
|
||||
boxShadow: 'rgb(115, 103, 240) 0px 0px 0px 1px',
|
||||
borderRadius: '3px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderImage: 'initial',
|
||||
borderColor: '#414561',
|
||||
},
|
||||
':active': {
|
||||
boxShadow: 'rgb(115, 103, 240) 0px 0px 0px 1px',
|
||||
borderRadius: '3px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderImage: 'initial',
|
||||
borderColor: 'rgb(115, 103, 240)',
|
||||
},
|
||||
};
|
||||
},
|
||||
menu: (styles: any) => {
|
||||
return {
|
||||
...styles,
|
||||
backgroundColor: mixin.darken('#262c49', 0.15),
|
||||
};
|
||||
},
|
||||
dropdownIndicator: (styles: any) => ({ ...styles, color: '#c2c6dc', ':hover': { color: '#c2c6dc' } }),
|
||||
indicatorSeparator: (styles: any) => ({ ...styles, color: '#c2c6dc' }),
|
||||
option: (styles: any, { data, isDisabled, isFocused, isSelected }: any) => {
|
||||
return {
|
||||
...styles,
|
||||
backgroundColor: isDisabled
|
||||
? null
|
||||
: isSelected
|
||||
? mixin.darken('#262c49', 0.25)
|
||||
: isFocused
|
||||
? mixin.darken('#262c49', 0.15)
|
||||
: null,
|
||||
color: isDisabled ? '#ccc' : isSelected ? '#fff' : '#c2c6dc',
|
||||
cursor: isDisabled ? 'not-allowed' : 'default',
|
||||
':active': {
|
||||
...styles[':active'],
|
||||
backgroundColor: !isDisabled && (isSelected ? mixin.darken('#262c49', 0.25) : '#fff'),
|
||||
},
|
||||
':hover': {
|
||||
...styles[':hover'],
|
||||
backgroundColor: !isDisabled && (isSelected ? 'rgb(115, 103, 240)' : 'rgb(115, 103, 240)'),
|
||||
},
|
||||
};
|
||||
},
|
||||
placeholder: (styles: any) => ({ ...styles, color: '#c2c6dc' }),
|
||||
clearIndicator: (styles: any) => ({ ...styles, color: '#c2c6dc', ':hover': { color: '#c2c6dc' } }),
|
||||
input: (styles: any) => ({
|
||||
...styles,
|
||||
color: '#fff',
|
||||
}),
|
||||
singleValue: (styles: any) => {
|
||||
return {
|
||||
...styles,
|
||||
color: '#fff',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const InputLabel = styled.span<{ width: string }>`
|
||||
width: ${props => props.width};
|
||||
padding-left: 0.7rem;
|
||||
color: rgba(115, 103, 240);
|
||||
left: 0;
|
||||
top: 0;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
cursor: text;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const SelectContainer = styled.div`
|
||||
position: relative;
|
||||
padding-top: 24px;
|
||||
`;
|
||||
|
||||
type SelectProps = {
|
||||
label?: string;
|
||||
onChange: (e: any) => void;
|
||||
value: any;
|
||||
options: Array<any>;
|
||||
};
|
||||
|
||||
const SelectElement: React.FC<SelectProps> = ({ onChange, value, options, label }) => {
|
||||
return (
|
||||
<SelectContainer>
|
||||
<Select
|
||||
onChange={(e: any) => {
|
||||
onChange(e);
|
||||
}}
|
||||
value={value}
|
||||
styles={colourStyles}
|
||||
classNamePrefix="teamSelect"
|
||||
options={options}
|
||||
/>
|
||||
{label && <InputLabel width="100%">{label}</InputLabel>}
|
||||
</SelectContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectElement;
|
35
frontend/src/shared/components/Settings/Settings.stories.tsx
Normal file
35
frontend/src/shared/components/Settings/Settings.stories.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React, { createRef, useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import Settings from '.';
|
||||
|
||||
export default {
|
||||
component: Settings,
|
||||
title: 'Settings',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const profile = {
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
username: 'jordanthedev',
|
||||
profileIcon: { url: 'http://localhost:3333/uploads/headshot.png', bgColor: '#000', initials: 'JK' },
|
||||
};
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Settings
|
||||
profile={profile}
|
||||
onProfileAvatarRemove={action('remove')}
|
||||
onProfileAvatarChange={action('profile avatar change')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
284
frontend/src/shared/components/Settings/index.tsx
Normal file
284
frontend/src/shared/components/Settings/index.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { User } from 'shared/icons';
|
||||
import Input from 'shared/components/Input';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
const ProfileContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2.2rem !important;
|
||||
`;
|
||||
|
||||
const AvatarContainer = styled.div`
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
`;
|
||||
const AvatarMask = styled.div<{ background: string }>`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: ${props => props.background};
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const AvatarImg = styled.img<{ src: string }>`
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
`;
|
||||
const UploadButton = styled(Button)`
|
||||
margin-right: 1rem;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const RemoveButton = styled(Button)`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const ImgLabel = styled.p`
|
||||
color: #c2c6dc;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 12.25px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AvatarInitials = styled.span`
|
||||
font-size: 32px;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
type AvatarSettingsProps = {
|
||||
onProfileAvatarChange: () => void;
|
||||
onProfileAvatarRemove: () => void;
|
||||
profile: ProfileIcon;
|
||||
};
|
||||
|
||||
const AvatarSettings: React.FC<AvatarSettingsProps> = ({ profile, onProfileAvatarChange, onProfileAvatarRemove }) => {
|
||||
return (
|
||||
<ProfileContainer>
|
||||
<AvatarContainer>
|
||||
<AvatarMask
|
||||
background={profile.url ? 'none' : profile.bgColor ?? 'none'}
|
||||
onClick={() => onProfileAvatarChange()}
|
||||
>
|
||||
{profile.url ? (
|
||||
<AvatarImg alt="" src={profile.url ?? ''} />
|
||||
) : (
|
||||
<AvatarInitials>{profile.initials}</AvatarInitials>
|
||||
)}
|
||||
</AvatarMask>
|
||||
</AvatarContainer>
|
||||
<ActionButtons>
|
||||
<UploadButton onClick={() => onProfileAvatarChange()}>Upload photo</UploadButton>
|
||||
<RemoveButton variant="outline" color="danger" onClick={() => onProfileAvatarRemove()}>
|
||||
Remove
|
||||
</RemoveButton>
|
||||
<ImgLabel>Allowed JPG, GIF or PNG. Max size of 800kB</ImgLabel>
|
||||
</ActionButtons>
|
||||
</ProfileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 2.2rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNav = styled.div`
|
||||
float: left;
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNavContent = styled.ul`
|
||||
display: block;
|
||||
width: auto;
|
||||
border-bottom: 0 !important;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const TabNavItem = styled.li`
|
||||
padding: 0.35rem 0.3rem;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
const TabNavItemButton = styled.button<{ active: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')};
|
||||
&:hover {
|
||||
color: rgba(115, 103, 240);
|
||||
}
|
||||
&:hover svg {
|
||||
fill: rgba(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
const TabNavItemSpan = styled.span`
|
||||
text-align: left;
|
||||
padding-left: 9px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const TabNavLine = styled.span<{ top: number }>`
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: 2px;
|
||||
height: 48px;
|
||||
transform: scaleX(1);
|
||||
top: ${props => props.top}px;
|
||||
|
||||
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240));
|
||||
box-shadow: 0 0 8px 0 rgba(115, 103, 240);
|
||||
display: block;
|
||||
position: absolute;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
const TabContentWrapper = styled.div`
|
||||
margin-left: 1rem;
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const TabContent = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 0;
|
||||
padding: 1.5rem;
|
||||
background-color: #10163a;
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
const TabContentInner = styled.div``;
|
||||
|
||||
const items = [{ name: 'General' }, { name: 'Change Password' }, { name: 'Info' }, { name: 'Notifications' }];
|
||||
type NavItemProps = {
|
||||
active: boolean;
|
||||
name: string;
|
||||
tab: number;
|
||||
onClick: (tab: number, top: number) => void;
|
||||
};
|
||||
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
||||
const $item = useRef<HTMLLIElement>(null);
|
||||
return (
|
||||
<TabNavItem
|
||||
key={name}
|
||||
ref={$item}
|
||||
onClick={() => {
|
||||
if ($item && $item.current) {
|
||||
const pos = $item.current.getBoundingClientRect();
|
||||
onClick(tab, pos.top);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabNavItemButton active={active}>
|
||||
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
||||
</TabNavItemButton>
|
||||
</TabNavItem>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const SaveButton = styled(Button)`
|
||||
margin-right: 1rem;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
type SettingsProps = {
|
||||
onProfileAvatarChange: () => void;
|
||||
onProfileAvatarRemove: () => void;
|
||||
profile: TaskUser;
|
||||
};
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({ onProfileAvatarRemove, onProfileAvatarChange, profile }) => {
|
||||
const [currentTab, setTab] = useState(0);
|
||||
const [currentTop, setTop] = useState(0);
|
||||
const $tabNav = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Container>
|
||||
<TabNav ref={$tabNav}>
|
||||
<TabNavContent>
|
||||
{items.map((item, idx) => (
|
||||
<NavItem
|
||||
onClick={(tab, top) => {
|
||||
if ($tabNav && $tabNav.current) {
|
||||
const pos = $tabNav.current.getBoundingClientRect();
|
||||
setTab(tab);
|
||||
setTop(top - pos.top);
|
||||
}
|
||||
}}
|
||||
name={item.name}
|
||||
tab={idx}
|
||||
active={idx === currentTab}
|
||||
/>
|
||||
))}
|
||||
<TabNavLine top={currentTop} />
|
||||
</TabNavContent>
|
||||
</TabNav>
|
||||
<TabContentWrapper>
|
||||
<TabContent>
|
||||
<AvatarSettings
|
||||
onProfileAvatarRemove={onProfileAvatarRemove}
|
||||
onProfileAvatarChange={onProfileAvatarChange}
|
||||
profile={profile.profileIcon}
|
||||
/>
|
||||
<Input value={profile.fullName} width="100%" label="Name" />
|
||||
<Input
|
||||
value={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
||||
width="100%"
|
||||
label="Initials "
|
||||
/>
|
||||
<Input value={profile.username ?? ''} width="100%" label="Username " />
|
||||
<Input width="100%" label="Email" />
|
||||
<Input width="100%" label="Bio" />
|
||||
<SettingActions>
|
||||
<SaveButton>Save Change</SaveButton>
|
||||
</SettingActions>
|
||||
</TabContent>
|
||||
</TabContentWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
27
frontend/src/shared/components/Sidebar/Sidebar.stories.tsx
Normal file
27
frontend/src/shared/components/Sidebar/Sidebar.stories.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import Navbar from 'shared/components/Navbar';
|
||||
import Sidebar from '.';
|
||||
|
||||
export default {
|
||||
component: Sidebar,
|
||||
title: 'Sidebar',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Navbar />
|
||||
<Sidebar />
|
||||
</>
|
||||
);
|
||||
};
|
17
frontend/src/shared/components/Sidebar/Styles.ts
Normal file
17
frontend/src/shared/components/Sidebar/Styles.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Container = styled.div`
|
||||
position: fixed;
|
||||
z-index: 99;
|
||||
top: 0px;
|
||||
left: 80px;
|
||||
height: 100vh;
|
||||
width: 230px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0px 16px 24px;
|
||||
background: rgb(244, 245, 247);
|
||||
border-right: 1px solid rgb(223, 225, 230);
|
||||
`;
|
||||
|
||||
export default Container;
|
9
frontend/src/shared/components/Sidebar/index.tsx
Normal file
9
frontend/src/shared/components/Sidebar/index.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
import Container from './Styles';
|
||||
|
||||
const Sidebar = () => {
|
||||
return <Container />;
|
||||
};
|
||||
|
||||
export default Sidebar;
|
25
frontend/src/shared/components/Tabs/Tabs.stories.tsx
Normal file
25
frontend/src/shared/components/Tabs/Tabs.stories.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React, { useRef } from 'react';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import Tabs from '.';
|
||||
|
||||
export default {
|
||||
component: Tabs,
|
||||
title: 'Tabs',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Tabs />
|
||||
</>
|
||||
);
|
||||
};
|
8
frontend/src/shared/components/Tabs/index.tsx
Normal file
8
frontend/src/shared/components/Tabs/index.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Tabs = () => {
|
||||
return <span>HEllo!</span>;
|
||||
};
|
||||
|
||||
export default Tabs;
|
76
frontend/src/shared/components/TaskAssignee/index.tsx
Normal file
76
frontend/src/shared/components/TaskAssignee/index.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React, { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { DoubleChevronUp, Crown } from 'shared/icons';
|
||||
|
||||
export const AdminIcon = styled(DoubleChevronUp)`
|
||||
bottom: 0;
|
||||
right: 1px;
|
||||
position: absolute;
|
||||
fill: #c377e0;
|
||||
`;
|
||||
|
||||
export const OwnerIcon = styled(Crown)`
|
||||
bottom: 0;
|
||||
right: 1px;
|
||||
position: absolute;
|
||||
fill: #c8b928;
|
||||
`;
|
||||
|
||||
const TaskDetailAssignee = styled.div`
|
||||
cursor: pointer;
|
||||
margin: 0 0 0 -2px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
float: left;
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>`
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(${props => (props.backgroundURL ? props.theme.colors.text.primary : '0,0,0')});
|
||||
background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
type TaskAssigneeProps = {
|
||||
size: number | string;
|
||||
showRoleIcons?: boolean;
|
||||
member: TaskUser;
|
||||
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TaskAssignee: React.FC<TaskAssigneeProps> = ({ showRoleIcons, member, onMemberProfile, size, className }) => {
|
||||
const $memberRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<TaskDetailAssignee
|
||||
className={className}
|
||||
ref={$memberRef}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onMemberProfile($memberRef, member.id);
|
||||
}}
|
||||
key={member.id}
|
||||
>
|
||||
<Wrapper backgroundURL={member.profileIcon.url ?? null} bgColor={member.profileIcon.bgColor ?? null} size={size}>
|
||||
{(!member.profileIcon.url && member.profileIcon.initials) ?? ''}
|
||||
</Wrapper>
|
||||
{showRoleIcons && member.role && member.role.code === 'admin' && <AdminIcon width={10} height={10} />}
|
||||
{showRoleIcons && member.role && member.role.code === 'owner' && <OwnerIcon width={10} height={10} />}
|
||||
</TaskDetailAssignee>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskAssignee;
|
391
frontend/src/shared/components/TaskDetails/Styles.ts
Normal file
391
frontend/src/shared/components/TaskDetails/Styles.ts
Normal file
@ -0,0 +1,391 @@
|
||||
import styled from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea/lib';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Button from 'shared/components/Button';
|
||||
|
||||
export const TaskHeader = styled.div`
|
||||
padding: 21px 30px 0px;
|
||||
margin-right: 70px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const TaskMeta = styled.div`
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export const TaskGroupLabel = styled.span`
|
||||
color: #c2c6dc;
|
||||
font-size: 14px;
|
||||
`;
|
||||
export const TaskGroupLabelName = styled.span`
|
||||
color: #c2c6dc;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
export const TaskActions = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 21px 18px 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const TaskAction = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
padding: 0px 9px;
|
||||
`;
|
||||
|
||||
export const TaskDetailsWrapper = styled.div`
|
||||
display: flex;
|
||||
padding: 0px 16px 60px;
|
||||
`;
|
||||
|
||||
export const TaskDetailsContent = styled.div`
|
||||
flex: 1;
|
||||
padding-right: 8px;
|
||||
`;
|
||||
|
||||
export const TaskDetailsSidebar = styled.div`
|
||||
width: 168px;
|
||||
padding-left: 8px;
|
||||
`;
|
||||
|
||||
export const TaskDetailsTitleWrapper = styled.div`
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
margin: 0 0 0 -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: 4px;
|
||||
background: #262c49;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
transition: background 0.1s ease 0s;
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
color: #c2c6dc;
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const TaskDetailsLabel = styled.div`
|
||||
padding: 24px 0px 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const TaskDetailsAddDetailsButton = styled.div`
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
min-height: 56px;
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: #c2c6dc;
|
||||
&:hover {
|
||||
background: ${mixin.darken('#262c49', 0.25)};
|
||||
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;
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
background: ${mixin.darken('#262c49', 0.05)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const TaskDetailsMarkdown = styled.div`
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
color: #c2c6dc;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
ul > li {
|
||||
margin: 8px 8px 8px 24px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
p a {
|
||||
color: rgba(${props => props.theme.colors.primary});
|
||||
}
|
||||
`;
|
||||
|
||||
export const TaskDetailsControls = styled.div`
|
||||
clear: both;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const ConfirmSave = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
`;
|
||||
|
||||
export const CancelEdit = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const TaskDetailSectionTitle = styled.div`
|
||||
text-transform: uppercase;
|
||||
color: #c2c6dc;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0px 5px;
|
||||
`;
|
||||
|
||||
export const TaskDetailAssignees = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const TaskDetailAssignee = styled.div`
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export const ProfileIcon = styled.div`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: rgb(115, 103, 240);
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const TaskDetailsAddMemberIcon = styled.div`
|
||||
float: left;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TaskDetailLabels = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
export const TaskDetailLabel = styled.div<{ color: string }>`
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
background-color: ${props => props.color};
|
||||
color: #fff;
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
float: left;
|
||||
font-weight: 600;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
margin: 0 4px 4px 0;
|
||||
min-width: 40px;
|
||||
padding: 0 12px;
|
||||
width: auto;
|
||||
`;
|
||||
|
||||
export const TaskDetailsAddLabel = styled.div`
|
||||
border-radius: 3px;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TaskDetailsAddLabelIcon = styled.div`
|
||||
float: left;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
export const NoDueDateLabel = styled.span`
|
||||
color: rgb(137, 147, 164);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const UnassignedLabel = styled.div`
|
||||
color: rgb(137, 147, 164);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
export const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionButtonsTitle = styled.h3`
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
`;
|
||||
|
||||
export const ActionButton = styled(Button)`
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
||||
text-align: left;
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(${props => props.theme.colors.bg.primary}, 0.6);
|
||||
}
|
||||
`;
|
||||
|
||||
export const MetaDetails = styled.div`
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
export const TaskDueDateButton = styled(Button)`
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(${props => props.theme.colors.bg.primary}, 0.6);
|
||||
}
|
||||
`;
|
||||
|
||||
export const MetaDetail = styled.div`
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 0 16px 8px 0;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
export const TaskDetailsSection = styled.div`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export const MetaDetailTitle = styled.h3`
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
margin: 0 8px 4px 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const MetaDetailContent = styled.div``;
|
@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
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
|
||||
onDeleteItem={action('delete item')}
|
||||
onChangeItemName={action('change item name')}
|
||||
task={{
|
||||
id: '1',
|
||||
taskGroup: { name: 'General', id: '1' },
|
||||
name: 'Hello, world',
|
||||
position: 1,
|
||||
labels: [
|
||||
{
|
||||
id: 'soft-skills',
|
||||
assignedDate: new Date().toString(),
|
||||
projectLabel: {
|
||||
createdDate: new Date().toString(),
|
||||
id: 'label-soft-skills',
|
||||
name: 'Soft Skills',
|
||||
labelColor: {
|
||||
id: '1',
|
||||
name: 'white',
|
||||
colorHex: '#fff',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
description,
|
||||
assigned: [
|
||||
{
|
||||
id: '1',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
fullName: 'Jordan Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onTaskNameChange={action('task name change')}
|
||||
onTaskDescriptionChange={(_task, desc) => setDescription(desc)}
|
||||
onDeleteTask={action('delete task')}
|
||||
onCloseModal={action('close modal')}
|
||||
onMemberProfile={action('profile')}
|
||||
onOpenAddMemberPopup={action('open add member popup')}
|
||||
onAddItem={action('add item')}
|
||||
onToggleTaskComplete={action('toggle task complete')}
|
||||
onToggleChecklistItem={action('toggle checklist item')}
|
||||
onOpenAddLabelPopup={action('open add label popup')}
|
||||
onChangeChecklistName={action('change checklist name')}
|
||||
onDeleteChecklist={action('delete checklist')}
|
||||
onOpenAddChecklistPopup={action(' open checklist')}
|
||||
onOpenDueDatePopop={action('open due date popup')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
340
frontend/src/shared/components/TaskDetails/index.tsx
Normal file
340
frontend/src/shared/components/TaskDetails/index.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Bin, Cross, Plus } from 'shared/icons';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import moment from 'moment';
|
||||
|
||||
import {
|
||||
NoDueDateLabel,
|
||||
TaskDueDateButton,
|
||||
UnassignedLabel,
|
||||
TaskGroupLabel,
|
||||
TaskGroupLabelName,
|
||||
TaskDetailsSection,
|
||||
TaskActions,
|
||||
TaskDetailsAddLabel,
|
||||
TaskDetailsAddLabelIcon,
|
||||
TaskAction,
|
||||
TaskMeta,
|
||||
ActionButtons,
|
||||
ActionButton,
|
||||
ActionButtonsTitle,
|
||||
TaskHeader,
|
||||
ProfileIcon,
|
||||
TaskDetailsContent,
|
||||
TaskDetailsWrapper,
|
||||
TaskDetailsSidebar,
|
||||
TaskDetailsTitleWrapper,
|
||||
TaskDetailsTitle,
|
||||
TaskDetailsLabel,
|
||||
TaskDetailsAddDetailsButton,
|
||||
TaskDetailsEditor,
|
||||
TaskDetailsEditorWrapper,
|
||||
TaskDetailsMarkdown,
|
||||
TaskDetailsControls,
|
||||
ConfirmSave,
|
||||
CancelEdit,
|
||||
TaskDetailSectionTitle,
|
||||
TaskDetailLabel,
|
||||
TaskDetailLabels,
|
||||
TaskDetailAssignee,
|
||||
TaskDetailAssignees,
|
||||
TaskDetailsAddMemberIcon,
|
||||
MetaDetails,
|
||||
MetaDetail,
|
||||
MetaDetailTitle,
|
||||
MetaDetailContent,
|
||||
} from './Styles';
|
||||
import Checklist from '../Checklist';
|
||||
|
||||
type TaskContentProps = {
|
||||
onEditContent: () => void;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type TaskLabelProps = {
|
||||
label: TaskLabel;
|
||||
onClick: ($target: React.RefObject<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const TaskLabelItem: React.FC<TaskLabelProps> = ({ label, onClick }) => {
|
||||
const $label = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<TaskDetailLabel
|
||||
onClick={() => {
|
||||
onClick($label);
|
||||
}}
|
||||
ref={$label}
|
||||
color={label.projectLabel.labelColor.colorHex}
|
||||
>
|
||||
{label.projectLabel.name}
|
||||
</TaskDetailLabel>
|
||||
);
|
||||
};
|
||||
|
||||
const TaskContent: React.FC<TaskContentProps> = ({ description, onEditContent }) => {
|
||||
return description === '' ? (
|
||||
<TaskDetailsAddDetailsButton onClick={onEditContent}>Add a more detailed description</TaskDetailsAddDetailsButton>
|
||||
) : (
|
||||
<TaskDetailsMarkdown onClick={onEditContent}>
|
||||
<ReactMarkdown source={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 variant="relief" onClick={handleOutsideClick}>
|
||||
Save
|
||||
</ConfirmSave>
|
||||
<CancelEdit onClick={onCancel}>
|
||||
<Plus size={16} color="#c2c6dc" />
|
||||
</CancelEdit>
|
||||
</TaskDetailsControls>
|
||||
</TaskDetailsEditorWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskDetailsProps = {
|
||||
task: Task;
|
||||
onTaskNameChange: (task: Task, newName: string) => void;
|
||||
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
|
||||
onDeleteTask: (task: Task) => void;
|
||||
onAddItem: (checklistID: string, name: string, position: number) => void;
|
||||
onDeleteItem: (itemID: string) => void;
|
||||
onChangeItemName: (itemID: string, itemName: string) => void;
|
||||
onToggleTaskComplete: (task: Task) => void;
|
||||
onToggleChecklistItem: (itemID: string, complete: boolean) => void;
|
||||
onOpenAddMemberPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
||||
onChangeChecklistName: (checklistID: string, name: string) => void;
|
||||
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
|
||||
onCloseModal: () => void;
|
||||
};
|
||||
|
||||
const TaskDetails: React.FC<TaskDetailsProps> = ({
|
||||
task,
|
||||
onDeleteChecklist,
|
||||
onTaskNameChange,
|
||||
onOpenAddChecklistPopup,
|
||||
onChangeChecklistName,
|
||||
onToggleTaskComplete,
|
||||
onTaskDescriptionChange,
|
||||
onChangeItemName,
|
||||
onDeleteItem,
|
||||
onDeleteTask,
|
||||
onCloseModal,
|
||||
onOpenAddMemberPopup,
|
||||
onOpenAddLabelPopup,
|
||||
onOpenDueDatePopop,
|
||||
onAddItem,
|
||||
onToggleChecklistItem,
|
||||
onMemberProfile,
|
||||
}) => {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [description, setDescription] = useState(task.description ?? '');
|
||||
const [taskName, setTaskName] = useState(task.name);
|
||||
const handleClick = () => {
|
||||
setEditorOpen(!editorOpen);
|
||||
};
|
||||
const handleDeleteTask = () => {
|
||||
onDeleteTask(task);
|
||||
};
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onTaskNameChange(task, taskName);
|
||||
}
|
||||
};
|
||||
const $unassignedRef = useRef<HTMLDivElement>(null);
|
||||
const $addMemberRef = useRef<HTMLDivElement>(null);
|
||||
const onUnassignedClick = () => {
|
||||
onOpenAddMemberPopup(task, $unassignedRef);
|
||||
};
|
||||
const onAddMember = ($target: React.RefObject<HTMLElement>) => {
|
||||
onOpenAddMemberPopup(task, $target);
|
||||
};
|
||||
const onAddChecklist = ($target: React.RefObject<HTMLElement>) => {
|
||||
onOpenAddChecklistPopup(task, $target)
|
||||
}
|
||||
const $dueDateLabel = useRef<HTMLDivElement>(null);
|
||||
const $addLabelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onAddLabel = ($target: React.RefObject<HTMLElement>) => {
|
||||
onOpenAddLabelPopup(task, $target);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<TaskActions>
|
||||
<TaskAction onClick={handleDeleteTask}>
|
||||
<Bin size={20} color="#c2c6dc" />
|
||||
</TaskAction>
|
||||
<TaskAction onClick={onCloseModal}>
|
||||
<Cross width={16} height={16} />
|
||||
</TaskAction>
|
||||
</TaskActions>
|
||||
<TaskHeader>
|
||||
<TaskDetailsTitleWrapper>
|
||||
<TaskDetailsTitle
|
||||
value={taskName}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setTaskName(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</TaskDetailsTitleWrapper>
|
||||
<TaskMeta>
|
||||
{task.taskGroup.name && (
|
||||
<TaskGroupLabel>
|
||||
in list <TaskGroupLabelName>{task.taskGroup.name}</TaskGroupLabelName>
|
||||
</TaskGroupLabel>
|
||||
)}
|
||||
</TaskMeta>
|
||||
</TaskHeader>
|
||||
<TaskDetailsWrapper>
|
||||
<TaskDetailsContent>
|
||||
<MetaDetails>
|
||||
{task.assigned && task.assigned.length !== 0 && (
|
||||
<MetaDetail>
|
||||
<MetaDetailTitle>MEMBERS</MetaDetailTitle>
|
||||
<MetaDetailContent>
|
||||
{task.assigned &&
|
||||
task.assigned.map(member => (
|
||||
<TaskAssignee key={member.id} size={32} member={member} onMemberProfile={onMemberProfile} />
|
||||
))}
|
||||
<TaskDetailsAddMemberIcon ref={$addMemberRef} onClick={() => onAddMember($addMemberRef)}>
|
||||
<Plus size={16} color="#c2c6dc" />
|
||||
</TaskDetailsAddMemberIcon>
|
||||
</MetaDetailContent>
|
||||
</MetaDetail>
|
||||
)}
|
||||
{task.labels.length !== 0 && (
|
||||
<MetaDetail>
|
||||
<MetaDetailTitle>LABELS</MetaDetailTitle>
|
||||
<MetaDetailContent>
|
||||
{task.labels.map(label => {
|
||||
return (
|
||||
<TaskLabelItem
|
||||
key={label.projectLabel.id}
|
||||
label={label}
|
||||
onClick={$target => {
|
||||
onOpenAddLabelPopup(task, $target);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<TaskDetailsAddLabelIcon ref={$addLabelRef} onClick={() => onAddLabel($addLabelRef)}>
|
||||
<Plus size={16} color="#c2c6dc" />
|
||||
</TaskDetailsAddLabelIcon>
|
||||
</MetaDetailContent>
|
||||
</MetaDetail>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<MetaDetail>
|
||||
<MetaDetailTitle>DUE DATE</MetaDetailTitle>
|
||||
<MetaDetailContent>
|
||||
<TaskDueDateButton>{moment(task.dueDate).format('MMM D [at] h:mm A')}</TaskDueDateButton>
|
||||
</MetaDetailContent>
|
||||
</MetaDetail>
|
||||
)}
|
||||
</MetaDetails>
|
||||
|
||||
<TaskDetailsSection>
|
||||
<TaskDetailsLabel>Description</TaskDetailsLabel>
|
||||
{editorOpen ? (
|
||||
<DetailsEditor
|
||||
description={description}
|
||||
onTaskDescriptionChange={newDescription => {
|
||||
setEditorOpen(false);
|
||||
setDescription(newDescription);
|
||||
onTaskDescriptionChange(task, newDescription);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setEditorOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TaskContent description={description} onEditContent={handleClick} />
|
||||
)}
|
||||
{task.checklists &&
|
||||
task.checklists
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map(checklist => (
|
||||
<Checklist
|
||||
key={checklist.id}
|
||||
name={checklist.name}
|
||||
checklistID={checklist.id}
|
||||
items={checklist.items}
|
||||
onDeleteChecklist={onDeleteChecklist}
|
||||
onChangeName={newName => onChangeChecklistName(checklist.id, newName)}
|
||||
onToggleItem={onToggleChecklistItem}
|
||||
onDeleteItem={onDeleteItem}
|
||||
onAddItem={n => {
|
||||
if (task.checklists) {
|
||||
let position = 65535;
|
||||
const [lastItem] = checklist.items.sort((a, b) => a.position - b.position).slice(-1);
|
||||
if (lastItem) {
|
||||
position = lastItem.position * 2 + 1;
|
||||
}
|
||||
onAddItem(checklist.id, n, position);
|
||||
}
|
||||
}}
|
||||
onChangeItemName={onChangeItemName}
|
||||
/>
|
||||
))}
|
||||
</TaskDetailsSection>
|
||||
</TaskDetailsContent>
|
||||
<TaskDetailsSidebar>
|
||||
<ActionButtons>
|
||||
<ActionButtonsTitle>ADD TO CARD</ActionButtonsTitle>
|
||||
<ActionButton onClick={() => onToggleTaskComplete(task)}>
|
||||
{task.complete ? 'Mark Incomplete' : 'Mark Complete'}
|
||||
</ActionButton>
|
||||
<ActionButton onClick={$target => onAddMember($target)}>Members</ActionButton>
|
||||
<ActionButton onClick={$target => onAddLabel($target)}>Labels</ActionButton>
|
||||
<ActionButton onClick={$target => onAddChecklist($target)}>Checklist</ActionButton>
|
||||
<ActionButton onClick={$target => onOpenDueDatePopop(task, $target)}>Due Date</ActionButton>
|
||||
<ActionButton>Attachment</ActionButton>
|
||||
<ActionButton>Cover</ActionButton>
|
||||
</ActionButtons>
|
||||
</TaskDetailsSidebar>
|
||||
</TaskDetailsWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDetails;
|
31
frontend/src/shared/components/Textarea/index.tsx
Normal file
31
frontend/src/shared/components/Textarea/index.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import styled from 'styled-components/macro';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
|
||||
const Textarea = styled(TextareaAutosize)`
|
||||
border: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
margin: -4px 0;
|
||||
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
text-transform: none;
|
||||
text-indent: 0px;
|
||||
text-shadow: none;
|
||||
flex-direction: column;
|
||||
text-align: start;
|
||||
|
||||
color: #c2c6dc;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
padding: 3px 10px 3px 8px;
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Textarea;
|
263
frontend/src/shared/components/TopNavbar/Styles.ts
Normal file
263
frontend/src/shared/components/TopNavbar/Styles.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import Button from 'shared/components/Button';
|
||||
import { Citadel } from 'shared/icons';
|
||||
import { NavLink, Link } from 'react-router-dom';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
|
||||
z-index: ${props => props.zIndex};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export const NavbarWrapper = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const ProjectMembers = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
export const NavbarHeader = styled.header`
|
||||
height: 80px;
|
||||
padding: 0 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgb(16, 22, 58);
|
||||
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
|
||||
`;
|
||||
export const Breadcrumbs = styled.div`
|
||||
color: rgb(94, 108, 132);
|
||||
font-size: 15px;
|
||||
`;
|
||||
export const BreadcrumpSeparator = styled.span`
|
||||
position: relative;
|
||||
top: 2px;
|
||||
font-size: 18px;
|
||||
margin: 0px 10px;
|
||||
`;
|
||||
|
||||
export const ProjectActions = styled.div`
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 1px;
|
||||
`;
|
||||
|
||||
export const GlobalActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const ProfileContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const ProfileNameWrapper = styled.div`
|
||||
text-align: right;
|
||||
line-height: 1.25;
|
||||
`;
|
||||
|
||||
export const IconContainer = styled.div<{ disabled?: boolean }>`
|
||||
margin-right: 20px;
|
||||
cursor: pointer;
|
||||
${props =>
|
||||
props.disabled &&
|
||||
css`
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const ProfileNamePrimary = styled.div`
|
||||
color: #c2c6dc;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
export const ProfileNameSecondary = styled.small`
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const ProfileIcon = styled.div<{ bgColor: string | null; backgroundURL: string | null }>`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
`;
|
||||
|
||||
export const ProjectMeta = styled.div<{ nameOnly?: boolean }>`
|
||||
display: flex;
|
||||
${props => !props.nameOnly && 'padding-top: 9px;'}
|
||||
margin-left: -14px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-height: 51px;
|
||||
`;
|
||||
|
||||
export const ProjectTabs = styled.div`
|
||||
align-items: flex-end;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
export const ProjectTab = styled(NavLink)`
|
||||
font-size: 80%;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
line-height: normal;
|
||||
min-width: 1px;
|
||||
transition-duration: 0.2s;
|
||||
transition-property: box-shadow, color;
|
||||
white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
padding-bottom: 12px;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 -2px rgba(${props => props.theme.colors.text.secondary});
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
}
|
||||
|
||||
&.active {
|
||||
box-shadow: inset 0 -2px rgba(${props => props.theme.colors.secondary});
|
||||
color: rgba(${props => props.theme.colors.secondary});
|
||||
}
|
||||
&.active:hover {
|
||||
box-shadow: inset 0 -2px rgba(${props => props.theme.colors.secondary});
|
||||
color: rgba(${props => props.theme.colors.secondary});
|
||||
}
|
||||
`;
|
||||
|
||||
export const ProjectName = styled.h1`
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
padding: 3px 10px 3px 8px;
|
||||
margin: -4px 0;
|
||||
`;
|
||||
export const ProjectNameTextarea = styled(TextareaAutosize)`
|
||||
border: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
margin: -4px 0;
|
||||
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
text-transform: none;
|
||||
text-indent: 0px;
|
||||
text-shadow: none;
|
||||
flex-direction: column;
|
||||
text-align: start;
|
||||
|
||||
color: #c2c6dc;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
padding: 3px 10px 3px 8px;
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ProjectSwitcher = styled.button`
|
||||
font-size: 20px;
|
||||
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 100px;
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
padding: 6px 4px;
|
||||
background-color: none;
|
||||
text-align: center;
|
||||
color: #c2c6dc;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
color: #c2c6dc;
|
||||
font-size: 20px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
`;
|
||||
|
||||
export const ProjectSettingsButton = styled.button`
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const InviteButton = styled(Button)`
|
||||
margin: 0 0 0 8px;
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
export const ProjectFinder = styled(Button)`
|
||||
margin-right: 20px;
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
|
||||
export const NavSeparator = styled.div`
|
||||
width: 1px;
|
||||
background: rgba(${props => props.theme.colors.border});
|
||||
height: 34px;
|
||||
margin: 0 20px;
|
||||
`;
|
||||
|
||||
export const LogoContainer = styled(Link)`
|
||||
display: block;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const CitadelTitle = styled.h2`
|
||||
margin-left: 5px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
font-size: 20px;
|
||||
`;
|
||||
|
||||
export const CitadelLogo = styled(Citadel)`
|
||||
fill: rgba(${props => props.theme.colors.text.primary});
|
||||
stroke: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
@ -0,0 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import DropdownMenu from 'shared/components/DropdownMenu';
|
||||
import TopNavbar from '.';
|
||||
|
||||
export default {
|
||||
component: TopNavbar,
|
||||
title: 'TopNavbar',
|
||||
|
||||
// Our exports that end in "Data" are not stories.
|
||||
excludeStories: /.*Data$/,
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
{ name: 'darkBlue', value: '#262c49', default: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<TopNavbar
|
||||
onOpenProjectFinder={action('finder')}
|
||||
name="Projects"
|
||||
user={{
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
profileIcon: {
|
||||
url: null,
|
||||
initials: 'JK',
|
||||
bgColor: '#000',
|
||||
},
|
||||
}}
|
||||
onChangeRole={action('change role')}
|
||||
onNotificationClick={action('notifications click')}
|
||||
onOpenSettings={action('open settings')}
|
||||
onDashboardClick={action('open dashboard')}
|
||||
onRemoveFromBoard={action('remove project')}
|
||||
onProfileClick={action('profile click')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
312
frontend/src/shared/components/TopNavbar/index.tsx
Normal file
312
frontend/src/shared/components/TopNavbar/index.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle } from 'shared/icons';
|
||||
import styled from 'styled-components';
|
||||
import ProfileIcon from 'shared/components/ProfileIcon';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { RoleCode } from 'shared/generated/graphql';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import {
|
||||
CitadelLogo,
|
||||
CitadelTitle,
|
||||
ProjectFinder,
|
||||
LogoContainer,
|
||||
NavSeparator,
|
||||
IconContainer,
|
||||
ProjectNameTextarea,
|
||||
InviteButton,
|
||||
GlobalActions,
|
||||
ProjectActions,
|
||||
ProjectMeta,
|
||||
ProjectName,
|
||||
ProjectTabs,
|
||||
ProjectTab,
|
||||
NavbarWrapper,
|
||||
NavbarHeader,
|
||||
ProjectSettingsButton,
|
||||
ProfileContainer,
|
||||
ProfileNameWrapper,
|
||||
ProfileNamePrimary,
|
||||
ProfileNameSecondary,
|
||||
ProjectMember,
|
||||
ProjectMembers,
|
||||
} from './Styles';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const HomeDashboard = styled(Home)``;
|
||||
|
||||
type ProjectHeadingProps = {
|
||||
onFavorite?: () => void;
|
||||
name: string;
|
||||
onSaveProjectName?: (projectName: string) => void;
|
||||
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const ProjectHeading: React.FC<ProjectHeadingProps> = ({
|
||||
onFavorite,
|
||||
name: initialProjectName,
|
||||
onSaveProjectName,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const [isEditProjectName, setEditProjectName] = useState(false);
|
||||
const [projectName, setProjectName] = useState(initialProjectName);
|
||||
const $projectName = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
if (isEditProjectName && $projectName && $projectName.current) {
|
||||
$projectName.current.focus();
|
||||
$projectName.current.select();
|
||||
}
|
||||
}, [isEditProjectName]);
|
||||
useEffect(() => {
|
||||
setProjectName(initialProjectName);
|
||||
}, [initialProjectName]);
|
||||
|
||||
const onProjectNameChange = (event: React.FormEvent<HTMLTextAreaElement>): void => {
|
||||
setProjectName(event.currentTarget.value);
|
||||
};
|
||||
const onProjectNameBlur = () => {
|
||||
if (onSaveProjectName) {
|
||||
onSaveProjectName(projectName);
|
||||
}
|
||||
setEditProjectName(false);
|
||||
};
|
||||
const onProjectNameKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if ($projectName && $projectName.current) {
|
||||
$projectName.current.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const $settings = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<>
|
||||
{isEditProjectName ? (
|
||||
<ProjectNameTextarea
|
||||
ref={$projectName}
|
||||
onChange={onProjectNameChange}
|
||||
onKeyDown={onProjectNameKeyDown}
|
||||
onBlur={onProjectNameBlur}
|
||||
spellCheck={false}
|
||||
value={projectName}
|
||||
/>
|
||||
) : (
|
||||
<ProjectName
|
||||
onClick={() => {
|
||||
setEditProjectName(true);
|
||||
}}
|
||||
>
|
||||
{projectName}
|
||||
</ProjectName>
|
||||
)}
|
||||
<ProjectSettingsButton
|
||||
onClick={() => {
|
||||
onOpenSettings($settings);
|
||||
}}
|
||||
ref={$settings}
|
||||
>
|
||||
<AngleDown color="#c2c6dc" />
|
||||
</ProjectSettingsButton>
|
||||
{onFavorite && (
|
||||
<ProjectSettingsButton onClick={() => onFavorite()}>
|
||||
<Star width={16} height={16} color="#c2c6dc" />
|
||||
</ProjectSettingsButton>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export type MenuItem = {
|
||||
name: string;
|
||||
link: string;
|
||||
};
|
||||
type MenuTypes = {
|
||||
[key: string]: Array<string>;
|
||||
};
|
||||
|
||||
export const MENU_TYPES: MenuTypes = {
|
||||
PROJECT_MENU: ['Board', 'Timeline', 'Calender'],
|
||||
TEAM_MENU: ['Projects', 'Members', 'Settings'],
|
||||
};
|
||||
|
||||
type NavBarProps = {
|
||||
menuType?: Array<MenuItem> | null;
|
||||
name: string | null;
|
||||
currentTab?: number;
|
||||
onSetTab?: (tab: number) => void;
|
||||
onOpenProjectFinder: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onChangeProjectOwner?: (userID: string) => void;
|
||||
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
|
||||
onFavorite?: () => void;
|
||||
onProfileClick: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onSaveName?: (name: string) => void;
|
||||
onNotificationClick: () => void;
|
||||
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onDashboardClick: () => void;
|
||||
user: TaskUser | null;
|
||||
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
|
||||
projectMembers?: Array<TaskUser> | null;
|
||||
onRemoveFromBoard?: (userID: string) => void;
|
||||
};
|
||||
|
||||
const NavBar: React.FC<NavBarProps> = ({
|
||||
menuType,
|
||||
onInviteUser,
|
||||
onChangeProjectOwner,
|
||||
currentTab,
|
||||
onOpenProjectFinder,
|
||||
onFavorite,
|
||||
onSetTab,
|
||||
onChangeRole,
|
||||
name,
|
||||
onRemoveFromBoard,
|
||||
onSaveName,
|
||||
onProfileClick,
|
||||
onNotificationClick,
|
||||
onDashboardClick,
|
||||
user,
|
||||
projectMembers,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const handleProfileClick = ($target: React.RefObject<HTMLElement>) => {
|
||||
if ($target && $target.current) {
|
||||
onProfileClick($target);
|
||||
}
|
||||
};
|
||||
const { showPopup } = usePopup();
|
||||
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
|
||||
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
|
||||
const warning =
|
||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
||||
if (member) {
|
||||
console.log(member);
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<MiniProfile
|
||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||
onChangeProjectOwner={
|
||||
member.role && member.role.code !== 'owner'
|
||||
? (userID: string) => {
|
||||
if (user && onChangeProjectOwner) {
|
||||
onChangeProjectOwner(userID);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
canChangeRole={member.role && member.role.code !== 'owner'}
|
||||
onChangeRole={roleCode => {
|
||||
if (onChangeRole) {
|
||||
onChangeRole(member.id, roleCode);
|
||||
}
|
||||
}}
|
||||
onRemoveFromBoard={
|
||||
member.role && member.role.code === 'owner'
|
||||
? undefined
|
||||
: () => {
|
||||
if (onRemoveFromBoard) {
|
||||
onRemoveFromBoard(member.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
user={member}
|
||||
bio=""
|
||||
/>,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NavbarWrapper>
|
||||
<NavbarHeader>
|
||||
<ProjectActions>
|
||||
<ProjectMeta>
|
||||
{name && (
|
||||
<ProjectHeading
|
||||
onFavorite={onFavorite}
|
||||
onOpenSettings={onOpenSettings}
|
||||
name={name}
|
||||
onSaveProjectName={onSaveName}
|
||||
/>
|
||||
)}
|
||||
</ProjectMeta>
|
||||
{name && (
|
||||
<ProjectTabs>
|
||||
{menuType &&
|
||||
menuType.map((menu, idx) => {
|
||||
return (
|
||||
<ProjectTab
|
||||
key={menu.name}
|
||||
to={menu.link}
|
||||
exact
|
||||
onClick={() => {
|
||||
// TODO
|
||||
}}
|
||||
>
|
||||
{menu.name}
|
||||
</ProjectTab>
|
||||
);
|
||||
})}
|
||||
</ProjectTabs>
|
||||
)}
|
||||
</ProjectActions>
|
||||
<LogoContainer to="/">
|
||||
<CitadelLogo width={24} height={24} />
|
||||
<CitadelTitle>Citadel</CitadelTitle>
|
||||
</LogoContainer>
|
||||
<GlobalActions>
|
||||
{projectMembers && (
|
||||
<>
|
||||
<ProjectMembers>
|
||||
{projectMembers.map((member, idx) => (
|
||||
<ProjectMember
|
||||
showRoleIcons
|
||||
zIndex={projectMembers.length - idx}
|
||||
key={member.id}
|
||||
size={28}
|
||||
member={member}
|
||||
onMemberProfile={onMemberProfile}
|
||||
/>
|
||||
))}
|
||||
<InviteButton
|
||||
onClick={$target => {
|
||||
if (onInviteUser) {
|
||||
onInviteUser($target);
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Invite
|
||||
</InviteButton>
|
||||
</ProjectMembers>
|
||||
<NavSeparator />
|
||||
</>
|
||||
)}
|
||||
<ProjectFinder onClick={onOpenProjectFinder} variant="gradient">
|
||||
Projects
|
||||
</ProjectFinder>
|
||||
<IconContainer onClick={onDashboardClick}>
|
||||
<HomeDashboard width={20} height={20} />
|
||||
</IconContainer>
|
||||
<IconContainer disabled>
|
||||
<CheckCircle width={20} height={20} />
|
||||
</IconContainer>
|
||||
<IconContainer onClick={onNotificationClick}>
|
||||
<Bell color="#c2c6dc" size={20} />
|
||||
</IconContainer>
|
||||
<IconContainer disabled>
|
||||
<BarChart width={20} height={20} />
|
||||
</IconContainer>
|
||||
|
||||
{user && (
|
||||
<IconContainer>
|
||||
<ProfileIcon user={user} size={30} onProfileClick={handleProfileClick} />
|
||||
</IconContainer>
|
||||
)}
|
||||
</GlobalActions>
|
||||
</NavbarHeader>
|
||||
</NavbarWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBar;
|
Reference in New Issue
Block a user