arch: move web folder into api & move api to top level
This commit is contained in:
234
frontend/src/Admin/index.tsx
Normal file
234
frontend/src/Admin/index.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Admin from 'shared/components/Admin';
|
||||
import Select from 'shared/components/Select';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import {
|
||||
useUsersQuery,
|
||||
useDeleteUserAccountMutation,
|
||||
useCreateUserAccountMutation,
|
||||
UsersDocument,
|
||||
UsersQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import Input from 'shared/components/Input';
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import produce from 'immer';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
|
||||
const DeleteUserWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const DeleteUserDescription = styled.p`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const DeleteUserButton = styled(Button)`
|
||||
margin-top: 6px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type DeleteUserPopupProps = {
|
||||
onDeleteUser: () => void;
|
||||
};
|
||||
const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
|
||||
return (
|
||||
<DeleteUserWrapper>
|
||||
<DeleteUserDescription>Deleting this user will remove all user related data.</DeleteUserDescription>
|
||||
<DeleteUserButton onClick={() => onDeleteUser()} color="danger">
|
||||
Delete user
|
||||
</DeleteUserButton>
|
||||
</DeleteUserWrapper>
|
||||
);
|
||||
};
|
||||
type CreateUserData = {
|
||||
email: string;
|
||||
username: string;
|
||||
fullName: string;
|
||||
initials: string;
|
||||
password: string;
|
||||
roleCode: string;
|
||||
};
|
||||
const CreateUserForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
const CreateUserButton = styled(Button)`
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AddUserInput = styled(Input)`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const InputError = styled.span`
|
||||
color: rgba(${props => props.theme.colors.danger});
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
type AddUserPopupProps = {
|
||||
onAddUser: (user: CreateUserData) => void;
|
||||
};
|
||||
|
||||
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
|
||||
const { register, handleSubmit, errors, setValue } = useForm<CreateUserData>();
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
register({ name: 'roleCode' }, { required: true });
|
||||
|
||||
const createUser = (data: CreateUserData) => {
|
||||
onAddUser(data);
|
||||
};
|
||||
return (
|
||||
<CreateUserForm onSubmit={handleSubmit(createUser)}>
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Full Name"
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
variant="alternate"
|
||||
ref={register({ required: 'Full name is required' })}
|
||||
/>
|
||||
{errors.fullName && <InputError>{errors.fullName.message}</InputError>}
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Email"
|
||||
id="email"
|
||||
name="email"
|
||||
variant="alternate"
|
||||
ref={register({ required: 'Email is required' })}
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
value={role}
|
||||
options={[
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Member', value: 'member' },
|
||||
]}
|
||||
onChange={newRole => {
|
||||
setRole(newRole);
|
||||
setValue('roleCode', newRole.value);
|
||||
}}
|
||||
/>
|
||||
{errors.email && <InputError>{errors.email.message}</InputError>}
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Username"
|
||||
id="username"
|
||||
name="username"
|
||||
variant="alternate"
|
||||
ref={register({ required: 'Username is required' })}
|
||||
/>
|
||||
{errors.username && <InputError>{errors.username.message}</InputError>}
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Initials"
|
||||
id="initials"
|
||||
name="initials"
|
||||
variant="alternate"
|
||||
ref={register({ required: 'Initials is required' })}
|
||||
/>
|
||||
{errors.initials && <InputError>{errors.initials.message}</InputError>}
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Password"
|
||||
id="password"
|
||||
name="password"
|
||||
variant="alternate"
|
||||
ref={register({ required: 'Password is required' })}
|
||||
/>
|
||||
{errors.password && <InputError>{errors.password.message}</InputError>}
|
||||
<CreateUserButton type="submit">Create</CreateUserButton>
|
||||
</CreateUserForm>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminRoute = () => {
|
||||
useEffect(() => {
|
||||
document.title = 'Citadel | Admin';
|
||||
}, []);
|
||||
const { loading, data } = useUsersQuery();
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const [deleteUser] = useDeleteUserAccountMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.users = cache.users.filter(u => u.id !== response.data.deleteUserAccount.userAccount.id);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const [createUser] = useCreateUserAccountMutation({
|
||||
update: (client, createData) => {
|
||||
const cacheData: any = client.readQuery({
|
||||
query: UsersDocument,
|
||||
});
|
||||
console.log(cacheData);
|
||||
console.log(createData);
|
||||
const newData = produce(cacheData, (draftState: any) => {
|
||||
draftState.users = [...draftState.users, { ...createData.data.createUserAccount }];
|
||||
});
|
||||
|
||||
client.writeQuery({
|
||||
query: UsersDocument,
|
||||
data: {
|
||||
...newData,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
if (loading) {
|
||||
return <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />;
|
||||
}
|
||||
if (data) {
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
|
||||
<Admin
|
||||
initialTab={1}
|
||||
users={data.users}
|
||||
onInviteUser={() => {}}
|
||||
onDeleteUser={($target, userID) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup tab={0} title="Delete user?" onClose={() => hidePopup()}>
|
||||
<DeleteUserPopup
|
||||
onDeleteUser={() => {
|
||||
deleteUser({ variables: { userID } });
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onAddUser={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup tab={0} title="Add member" onClose={() => hidePopup()}>
|
||||
<AddUserPopup
|
||||
onAddUser={user => {
|
||||
createUser({ variables: { ...user } });
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <span>error</span>;
|
||||
};
|
||||
|
||||
export default AdminRoute;
|
129
frontend/src/App/BaseStyles.ts
Normal file
129
frontend/src/App/BaseStyles.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export default createGlobalStyle`
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
min-width: 768px;
|
||||
background: #262c49;
|
||||
}
|
||||
|
||||
body {
|
||||
color: ${color.textDarkest};
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
line-height: 1.2;
|
||||
${font.size(16)}
|
||||
${font.regular}
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
${font.regular}
|
||||
}
|
||||
|
||||
*, *:after, *:before, input[type="search"] {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, strong {
|
||||
${font.bold}
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Workaround for IE11 focus highlighting for select elements */
|
||||
select::-ms-value {
|
||||
background: none;
|
||||
color: #42413d;
|
||||
}
|
||||
|
||||
[role="button"], button, input, select, textarea {
|
||||
outline: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
[role="button"], button, input, textarea {
|
||||
appearance: none;
|
||||
}
|
||||
select:-moz-focusring {
|
||||
color: transparent;
|
||||
text-shadow: 0 0 0 #000;
|
||||
}
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
select option {
|
||||
color: ${color.textDarkest};
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.4285;
|
||||
a {
|
||||
${mixin.link()}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
line-height: 1.4285;
|
||||
}
|
||||
|
||||
body, select {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #262c49;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #7367f0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
${mixin.placeholderColor(color.textLight)}
|
||||
`;
|
31
frontend/src/App/Navbar.tsx
Normal file
31
frontend/src/App/Navbar.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { Home, Stack } from 'shared/icons';
|
||||
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from 'shared/components/Navbar';
|
||||
import { Link } from 'react-router-dom';
|
||||
import UserIDContext from './context';
|
||||
|
||||
const GlobalNavbar = () => {
|
||||
const { userID } = useContext(UserIDContext);
|
||||
if (!userID) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Navbar>
|
||||
<PrimaryLogo />
|
||||
<ButtonContainer>
|
||||
<Link to="/">
|
||||
<ActionButton name="Home">
|
||||
<Home width={28} height={28} />
|
||||
</ActionButton>
|
||||
</Link>
|
||||
<Link to="/projects">
|
||||
<ActionButton name="Projects">
|
||||
<Stack size={28} color="#c2c6dc" />
|
||||
</ActionButton>
|
||||
</Link>
|
||||
</ButtonContainer>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalNavbar;
|
152
frontend/src/App/NormalizeStyles.ts
Normal file
152
frontend/src/App/NormalizeStyles.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
/** DO NOT ALTER THIS FILE. It is a copy of https://necolas.github.io/normalize.css/ */
|
||||
|
||||
export default createGlobalStyle`
|
||||
html {
|
||||
line-height: 1.15;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
hr {
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
pre {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
a {
|
||||
background-color: transparent;
|
||||
}
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: table;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
40
frontend/src/App/Routes.tsx
Normal file
40
frontend/src/App/Routes.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Router, Switch, Route } from 'react-router-dom';
|
||||
import * as H from 'history';
|
||||
|
||||
import Dashboard from 'Dashboard';
|
||||
import Admin from 'Admin';
|
||||
import Projects from 'Projects';
|
||||
import Project from 'Projects/Project';
|
||||
import Teams from 'Teams';
|
||||
import Login from 'Auth';
|
||||
import Profile from 'Profile';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 0 0;
|
||||
background: #262c49;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
type RoutesProps = {
|
||||
history: H.History;
|
||||
};
|
||||
|
||||
const Routes = ({ history }: RoutesProps) => (
|
||||
<Switch>
|
||||
<Route exact path="/login" component={Login} />
|
||||
<MainContent>
|
||||
<Route exact path="/" component={Dashboard} />
|
||||
<Route exact path="/projects" component={Projects} />
|
||||
<Route path="/projects/:projectID" component={Project} />
|
||||
<Route path="/teams/:teamID" component={Teams} />
|
||||
<Route path="/profile" component={Profile} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
</MainContent>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
export default Routes;
|
28
frontend/src/App/ThemeStyles.ts
Normal file
28
frontend/src/App/ThemeStyles.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { createGlobalStyle, DefaultTheme } from 'styled-components';
|
||||
|
||||
const theme: DefaultTheme = {
|
||||
borderRadius: {
|
||||
primary: '3px',
|
||||
alternate: '6px',
|
||||
},
|
||||
colors: {
|
||||
primary: '115, 103, 240',
|
||||
secondary: '216, 93, 216',
|
||||
alternate: '65, 69, 97',
|
||||
success: '40, 199, 111',
|
||||
danger: '234, 84, 85',
|
||||
warning: '255, 159, 67',
|
||||
dark: '30, 30, 30',
|
||||
text: {
|
||||
primary: '194, 198, 220',
|
||||
secondary: '255, 255, 255',
|
||||
},
|
||||
border: '65, 69, 97',
|
||||
bg: {
|
||||
primary: '16, 22, 58',
|
||||
secondary: '38, 44, 73',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { theme };
|
333
frontend/src/App/TopNavbar.tsx
Normal file
333
frontend/src/App/TopNavbar.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
|
||||
import styled from 'styled-components/macro';
|
||||
import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu';
|
||||
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
||||
import { useHistory } from 'react-router';
|
||||
import UserIDContext from 'App/context';
|
||||
import {
|
||||
RoleCode,
|
||||
useMeQuery,
|
||||
useDeleteProjectMutation,
|
||||
useGetProjectsQuery,
|
||||
GetProjectsDocument,
|
||||
} from 'shared/generated/graphql';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { History } from 'history';
|
||||
import produce from 'immer';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const TeamContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const TeamTitle = styled.h3`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const TeamProjects = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const TeamProjectLink = styled(Link)`
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const TeamProjectBackground = styled.div<{ color: string }>`
|
||||
background-image: url(null);
|
||||
background-color: ${props => props.color};
|
||||
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
opacity: 1;
|
||||
border-radius: 3px;
|
||||
&:before {
|
||||
background: rgba(${props => props.theme.colors.bg.secondary});
|
||||
bottom: 0;
|
||||
content: '';
|
||||
left: 0;
|
||||
opacity: 0.88;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TeamProjectAvatar = styled.div<{ color: string }>`
|
||||
background-image: url(null);
|
||||
background-color: ${props => props.color};
|
||||
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
background-size: cover;
|
||||
border-radius: 3px 0 0 3px;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
position: relative;
|
||||
opacity: 0.7;
|
||||
`;
|
||||
|
||||
const TeamProjectContent = styled.div`
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 9px 0 9px 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const TeamProjectTitle = styled.div`
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
padding-right: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const TeamProjectContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
margin: 0 4px 4px 0;
|
||||
min-width: 0;
|
||||
&:hover ${TeamProjectTitle} {
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
}
|
||||
&:hover ${TeamProjectAvatar} {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover ${TeamProjectBackground}:before {
|
||||
opacity: 0.78;
|
||||
}
|
||||
`;
|
||||
|
||||
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
|
||||
|
||||
const ProjectFinder = () => {
|
||||
const { loading, data } = useGetProjectsQuery();
|
||||
if (loading) {
|
||||
return <span>loading</span>;
|
||||
}
|
||||
if (data) {
|
||||
const { projects, teams, organizations } = data;
|
||||
const projectTeams = teams.map(team => {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
projects: projects.filter(project => project.team.id === team.id),
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{projectTeams.map(team => (
|
||||
<TeamContainer key={team.id}>
|
||||
<TeamTitle>{team.name}</TeamTitle>
|
||||
<TeamProjects>
|
||||
{team.projects.map((project, idx) => (
|
||||
<TeamProjectContainer key={project.id}>
|
||||
<TeamProjectLink to={`/projects/${project.id}`}>
|
||||
<TeamProjectBackground color={colors[idx % 5]} />
|
||||
<TeamProjectAvatar color={colors[idx % 5]} />
|
||||
<TeamProjectContent>
|
||||
<TeamProjectTitle>{project.name}</TeamProjectTitle>
|
||||
</TeamProjectContent>
|
||||
</TeamProjectLink>
|
||||
</TeamProjectContainer>
|
||||
))}
|
||||
</TeamProjects>
|
||||
</TeamContainer>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <span>error</span>;
|
||||
};
|
||||
type ProjectPopupProps = {
|
||||
history: History<History.PoorMansUnknown>;
|
||||
name: string;
|
||||
projectID: string;
|
||||
};
|
||||
|
||||
export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, projectID }) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
const [deleteProject] = useDeleteProjectMutation({
|
||||
update: (client, deleteData) => {
|
||||
const cacheData: any = client.readQuery({
|
||||
query: GetProjectsDocument,
|
||||
});
|
||||
|
||||
console.log(cacheData);
|
||||
console.log(deleteData);
|
||||
|
||||
const newData = produce(cacheData, (draftState: any) => {
|
||||
draftState.projects = draftState.projects.filter(
|
||||
(project: any) => project.id !== deleteData.data.deleteProject.project.id,
|
||||
);
|
||||
});
|
||||
|
||||
client.writeQuery({
|
||||
query: GetProjectsDocument,
|
||||
data: {
|
||||
...newData,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} tab={0}>
|
||||
<ProjectSettings
|
||||
onDeleteProject={() => {
|
||||
setTab(1);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup title={`Delete the "${name}" project?`} tab={1}>
|
||||
<DeleteConfirm
|
||||
description={DELETE_INFO.DELETE_PROJECTS.description}
|
||||
deletedItems={DELETE_INFO.DELETE_PROJECTS.deletedItems}
|
||||
onConfirmDelete={() => {
|
||||
if (projectID) {
|
||||
deleteProject({ variables: { projectID } });
|
||||
hidePopup();
|
||||
history.push('/projects');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type GlobalTopNavbarProps = {
|
||||
nameOnly?: boolean;
|
||||
projectID: string | null;
|
||||
onChangeProjectOwner?: (userID: string) => void;
|
||||
name: string | null;
|
||||
currentTab?: number;
|
||||
popupContent?: JSX.Element;
|
||||
menuType?: Array<MenuItem>;
|
||||
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
|
||||
projectMembers?: null | Array<TaskUser>;
|
||||
onSaveProjectName?: (projectName: string) => void;
|
||||
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onSetTab?: (tab: number) => void;
|
||||
onRemoveFromBoard?: (userID: string) => void;
|
||||
};
|
||||
|
||||
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
currentTab,
|
||||
onSetTab,
|
||||
menuType,
|
||||
projectID,
|
||||
onChangeProjectOwner,
|
||||
onChangeRole,
|
||||
name,
|
||||
popupContent,
|
||||
projectMembers,
|
||||
onInviteUser,
|
||||
onSaveProjectName,
|
||||
onRemoveFromBoard,
|
||||
nameOnly,
|
||||
}) => {
|
||||
console.log(popupContent);
|
||||
const { loading, data } = useMeQuery();
|
||||
const { showPopup, hidePopup, setTab } = usePopup();
|
||||
const history = useHistory();
|
||||
const { userID, setUserID } = useContext(UserIDContext);
|
||||
const onLogout = () => {
|
||||
fetch('http://localhost:3333/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
history.replace('/login');
|
||||
setUserID(null);
|
||||
hidePopup();
|
||||
}
|
||||
});
|
||||
};
|
||||
const onProfileClick = ($target: React.RefObject<HTMLElement>) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup title={null} tab={0}>
|
||||
<ProfileMenu
|
||||
onLogout={onLogout}
|
||||
onAdminConsole={() => {
|
||||
history.push('/admin');
|
||||
hidePopup();
|
||||
}}
|
||||
onProfile={() => {
|
||||
history.push('/profile');
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
195,
|
||||
);
|
||||
};
|
||||
|
||||
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
|
||||
console.log('maybe firing popup');
|
||||
if (popupContent) {
|
||||
console.log('showing popup');
|
||||
showPopup($target, popupContent, 185);
|
||||
}
|
||||
};
|
||||
|
||||
if (!userID) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TopNavbar
|
||||
name={name}
|
||||
menuType={menuType}
|
||||
onOpenProjectFinder={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup tab={0} title={null}>
|
||||
<ProjectFinder />
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
currentTab={currentTab}
|
||||
user={data ? data.me : null}
|
||||
onInviteUser={onInviteUser}
|
||||
onChangeRole={onChangeRole}
|
||||
onChangeProjectOwner={onChangeProjectOwner}
|
||||
onNotificationClick={() => {}}
|
||||
onSetTab={onSetTab}
|
||||
onRemoveFromBoard={onRemoveFromBoard}
|
||||
onDashboardClick={() => {
|
||||
history.push('/');
|
||||
}}
|
||||
projectMembers={projectMembers}
|
||||
onProfileClick={onProfileClick}
|
||||
onSaveName={onSaveProjectName}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalTopNavbar;
|
9
frontend/src/App/context.ts
Normal file
9
frontend/src/App/context.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
type UserIDContextState = {
|
||||
userID: string | null;
|
||||
setUserID: (userID: string | null) => void;
|
||||
};
|
||||
export const UserIDContext = React.createContext<UserIDContextState>({ userID: null, setUserID: _userID => null });
|
||||
|
||||
export default UserIDContext;
|
63
frontend/src/App/index.tsx
Normal file
63
frontend/src/App/index.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { setAccessToken } from 'shared/utils/accessToken';
|
||||
import styled, { ThemeProvider } from 'styled-components';
|
||||
import NormalizeStyles from './NormalizeStyles';
|
||||
import BaseStyles from './BaseStyles';
|
||||
import { theme } from './ThemeStyles';
|
||||
import Routes from './Routes';
|
||||
import { UserIDContext } from './context';
|
||||
import Navbar from './Navbar';
|
||||
import { Router } from 'react-router';
|
||||
import { PopupProvider } from 'shared/components/PopupMenu';
|
||||
|
||||
const history = createBrowserHistory();
|
||||
|
||||
const App = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userID, setUserID] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3333/auth/refresh_token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const { status } = x;
|
||||
if (status === 400) {
|
||||
history.replace('/login');
|
||||
} else {
|
||||
const response: RefreshTokenResponse = await x.json();
|
||||
const { accessToken } = response;
|
||||
const claims: JWTToken = jwtDecode(accessToken);
|
||||
setUserID(claims.userId);
|
||||
setAccessToken(accessToken);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserIDContext.Provider value={{ userID, setUserID }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Router history={history}>
|
||||
<PopupProvider>
|
||||
{loading ? (
|
||||
<div>loading</div>
|
||||
) : (
|
||||
<>
|
||||
<Routes history={history} />
|
||||
</>
|
||||
)}
|
||||
</PopupProvider>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
</UserIDContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
13
frontend/src/Auth/Styles.ts
Normal file
13
frontend/src/Auth/Styles.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
export const LoginWrapper = styled.div`
|
||||
width: 60%;
|
||||
`;
|
68
frontend/src/Auth/index.tsx
Normal file
68
frontend/src/Auth/index.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
import { setAccessToken } from 'shared/utils/accessToken';
|
||||
|
||||
import Login from 'shared/components/Login';
|
||||
import { Container, LoginWrapper } from './Styles';
|
||||
import UserIDContext from 'App/context';
|
||||
import JwtDecode from 'jwt-decode';
|
||||
|
||||
const Auth = () => {
|
||||
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
|
||||
const history = useHistory();
|
||||
const { setUserID } = useContext(UserIDContext);
|
||||
const login = (
|
||||
data: LoginFormData,
|
||||
setComplete: (val: boolean) => void,
|
||||
setError: (field: string, eType: string, message: string) => void,
|
||||
) => {
|
||||
fetch('http://localhost:3333/auth/login', {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
}),
|
||||
}).then(async x => {
|
||||
if (x.status === 401) {
|
||||
setInvalidLoginAttempt(invalidLoginAttempt + 1);
|
||||
setError('username', 'invalid', 'Invalid username');
|
||||
setError('password', 'invalid', 'Invalid password');
|
||||
setComplete(true);
|
||||
} else {
|
||||
const response = await x.json();
|
||||
const { accessToken } = response;
|
||||
const claims: JWTToken = JwtDecode(accessToken);
|
||||
setUserID(claims.userId);
|
||||
setComplete(true);
|
||||
setAccessToken(accessToken);
|
||||
|
||||
history.push('/');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:3333/auth/refresh_token', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
history.replace('/projects');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<LoginWrapper>
|
||||
<Login onSubmit={login} />
|
||||
</LoginWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Auth;
|
8
frontend/src/Dashboard/index.tsx
Normal file
8
frontend/src/Dashboard/index.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Redirect } from 'react-router';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
return <Redirect to="/projects" />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
71
frontend/src/Profile/index.tsx
Normal file
71
frontend/src/Profile/index.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getAccessToken } from 'shared/utils/accessToken';
|
||||
import Navbar from 'App/Navbar';
|
||||
import Settings from 'shared/components/Settings';
|
||||
import UserIDContext from 'App/context';
|
||||
import { useMeQuery, useClearProfileAvatarMutation } from 'shared/generated/graphql';
|
||||
import axios from 'axios';
|
||||
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 50px 80px;
|
||||
height: 100%;
|
||||
background: #262c49;
|
||||
`;
|
||||
|
||||
const Projects = () => {
|
||||
const $fileUpload = useRef<HTMLInputElement>(null);
|
||||
const [clearProfileAvatar] = useClearProfileAvatarMutation();
|
||||
const { loading, data, refetch } = useMeQuery();
|
||||
useEffect(() => {
|
||||
document.title = 'Profile | Citadel';
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
style={{ display: 'none' }}
|
||||
ref={$fileUpload}
|
||||
onChange={e => {
|
||||
if (e.target.files) {
|
||||
console.log(e.target.files[0]);
|
||||
const fileData = new FormData();
|
||||
fileData.append('file', e.target.files[0]);
|
||||
const accessToken = getAccessToken();
|
||||
axios
|
||||
.post('http://localhost:3333/users/me/avatar', fileData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
.then(res => {
|
||||
if ($fileUpload && $fileUpload.current) {
|
||||
$fileUpload.current.value = '';
|
||||
refetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
|
||||
{!loading && data && (
|
||||
<Settings
|
||||
profile={data.me}
|
||||
onProfileAvatarChange={() => {
|
||||
if ($fileUpload && $fileUpload.current) {
|
||||
$fileUpload.current.click();
|
||||
}
|
||||
}}
|
||||
onProfileAvatarRemove={() => {
|
||||
clearProfileAvatar();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
426
frontend/src/Projects/Project/Details/index.tsx
Normal file
426
frontend/src/Projects/Project/Details/index.tsx
Normal file
@ -0,0 +1,426 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import Modal from 'shared/components/Modal';
|
||||
import TaskDetails from 'shared/components/TaskDetails';
|
||||
import PopupMenu, { Popup, usePopup } from 'shared/components/PopupMenu';
|
||||
import MemberManager from 'shared/components/MemberManager';
|
||||
import { useRouteMatch, useHistory } from 'react-router';
|
||||
import {
|
||||
useDeleteTaskChecklistMutation,
|
||||
useUpdateTaskChecklistNameMutation,
|
||||
useCreateTaskChecklistMutation,
|
||||
useFindTaskQuery,
|
||||
useUpdateTaskDueDateMutation,
|
||||
useSetTaskCompleteMutation,
|
||||
useAssignTaskMutation,
|
||||
useUnassignTaskMutation,
|
||||
useSetTaskChecklistItemCompleteMutation,
|
||||
useDeleteTaskChecklistItemMutation,
|
||||
useUpdateTaskChecklistItemNameMutation,
|
||||
useCreateTaskChecklistItemMutation,
|
||||
FindTaskDocument,
|
||||
FindTaskQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import UserIDContext from 'App/context';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import DueDateManager from 'shared/components/DueDateManager';
|
||||
import produce from 'immer';
|
||||
import styled from 'styled-components';
|
||||
import Button from 'shared/components/Button';
|
||||
import Input from 'shared/components/Input';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
|
||||
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
|
||||
const total = checklists.reduce((prev: any, next: any) => {
|
||||
return (
|
||||
prev +
|
||||
next.items.reduce((innerPrev: any, _item: any) => {
|
||||
return innerPrev + 1;
|
||||
}, 0)
|
||||
);
|
||||
}, 0);
|
||||
const complete = checklists.reduce(
|
||||
(prev: any, next: any) =>
|
||||
prev +
|
||||
next.items.reduce((innerPrev: any, item: any) => {
|
||||
return innerPrev + (item.complete ? 1 : 0);
|
||||
}, 0),
|
||||
0,
|
||||
);
|
||||
return { total, complete };
|
||||
};
|
||||
|
||||
const DeleteChecklistButton = styled(Button)`
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
type CreateChecklistData = {
|
||||
name: string;
|
||||
};
|
||||
const CreateChecklistForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const CreateChecklistButton = styled(Button)`
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const CreateChecklistInput = styled(Input)`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const InputError = styled.span`
|
||||
color: rgba(${props => props.theme.colors.danger});
|
||||
font-size: 12px;
|
||||
`;
|
||||
type CreateChecklistPopupProps = {
|
||||
onCreateChecklist: (data: CreateChecklistData) => void;
|
||||
};
|
||||
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
|
||||
const { register, handleSubmit, errors } = useForm<CreateChecklistData>();
|
||||
const createUser = (data: CreateChecklistData) => {
|
||||
onCreateChecklist(data);
|
||||
};
|
||||
console.log(errors);
|
||||
return (
|
||||
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
|
||||
<CreateChecklistInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Name"
|
||||
id="name"
|
||||
name="name"
|
||||
variant="alternate"
|
||||
ref={register({ required: 'Checklist name is required' })}
|
||||
/>
|
||||
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
|
||||
</CreateChecklistForm>
|
||||
);
|
||||
};
|
||||
|
||||
type DetailsProps = {
|
||||
taskID: string;
|
||||
projectURL: string;
|
||||
onTaskNameChange: (task: Task, newName: string) => void;
|
||||
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
|
||||
onDeleteTask: (task: Task) => void;
|
||||
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
availableMembers: Array<TaskUser>;
|
||||
refreshCache: () => void;
|
||||
};
|
||||
|
||||
const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
|
||||
|
||||
const Details: React.FC<DetailsProps> = ({
|
||||
projectURL,
|
||||
taskID,
|
||||
onTaskNameChange,
|
||||
onTaskDescriptionChange,
|
||||
onDeleteTask,
|
||||
onOpenAddLabelPopup,
|
||||
availableMembers,
|
||||
refreshCache,
|
||||
}) => {
|
||||
const { userID } = useContext(UserIDContext);
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
const [currentMemberTask, setCurrentMemberTask] = useState('');
|
||||
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
|
||||
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
|
||||
update: client => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
complete,
|
||||
total,
|
||||
};
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [deleteTaskChecklist] = useDeleteTaskChecklistMutation({
|
||||
update: (client, deleteData) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const { checklists } = cache.findTask;
|
||||
const item = deleteData.deleteTaskChecklist;
|
||||
draftCache.findTask.checklists = checklists.filter(c => c.id !== item.taskChecklist.id);
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
complete,
|
||||
total,
|
||||
};
|
||||
if (complete === 0 && total === 0) {
|
||||
draftCache.findTask.badges.checklist = null;
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation();
|
||||
const [createTaskChecklist] = useCreateTaskChecklistMutation({
|
||||
update: (client, createData) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const item = createData.data.createTaskChecklist;
|
||||
draftCache.findTask.checklists.push({ ...item });
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation();
|
||||
const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({
|
||||
update: (client, deleteData) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
||||
draftCache.findTask.checklists = cache.findTask.checklists.filter(c => item.id !== c.id);
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
complete,
|
||||
total,
|
||||
};
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
|
||||
update: (client, newTaskItem) => {
|
||||
updateApolloCache<FindTaskQuery>(
|
||||
client,
|
||||
FindTaskDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const item = newTaskItem.data.createTaskChecklistItem;
|
||||
const { checklists } = cache.findTask;
|
||||
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
|
||||
if (idx !== -1) {
|
||||
draftCache.findTask.checklists[idx].items.push({ ...item });
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
complete,
|
||||
total,
|
||||
};
|
||||
}
|
||||
}),
|
||||
{ taskID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
|
||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
|
||||
onCompleted: () => {
|
||||
refetch();
|
||||
refreshCache();
|
||||
},
|
||||
});
|
||||
const [assignTask] = useAssignTaskMutation({
|
||||
onCompleted: () => {
|
||||
refetch();
|
||||
refreshCache();
|
||||
},
|
||||
});
|
||||
const [unassignTask] = useUnassignTaskMutation({
|
||||
onCompleted: () => {
|
||||
refetch();
|
||||
refreshCache();
|
||||
},
|
||||
});
|
||||
if (loading) {
|
||||
return <div>loading</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <div>loading</div>;
|
||||
}
|
||||
console.log(data.findTask);
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
width={768}
|
||||
onClose={() => {
|
||||
history.push(projectURL);
|
||||
}}
|
||||
renderContent={() => {
|
||||
return (
|
||||
<TaskDetails
|
||||
task={data.findTask}
|
||||
onTaskNameChange={onTaskNameChange}
|
||||
onTaskDescriptionChange={onTaskDescriptionChange}
|
||||
onToggleTaskComplete={task => {
|
||||
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
||||
}}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onChangeItemName={(itemID, itemName) => {
|
||||
updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } });
|
||||
}}
|
||||
onCloseModal={() => history.push(projectURL)}
|
||||
onChangeChecklistName={(checklistID, newName) => {
|
||||
updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } });
|
||||
}}
|
||||
onDeleteItem={itemID => {
|
||||
deleteTaskChecklistItem({ variables: { taskChecklistItemID: itemID } });
|
||||
}}
|
||||
onToggleChecklistItem={(itemID, complete) => {
|
||||
setTaskChecklistItemComplete({
|
||||
variables: { taskChecklistItemID: itemID, complete },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
setTaskChecklistItemComplete: {
|
||||
__typename: 'TaskChecklistItem',
|
||||
id: itemID,
|
||||
complete,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onAddItem={(taskChecklistID, name, position) => {
|
||||
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
|
||||
}}
|
||||
onMemberProfile={($targetRef, memberID) => {
|
||||
const member = data.findTask.assigned.find(m => m.id === memberID);
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title={null} onClose={() => {}} tab={0}>
|
||||
<MiniProfile
|
||||
user={member}
|
||||
bio="None"
|
||||
onRemoveFromTask={() => {
|
||||
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onOpenAddMemberPopup={(task, $targetRef) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title="Members" tab={0} onClose={() => {}}>
|
||||
<MemberManager
|
||||
availableMembers={availableMembers}
|
||||
activeMembers={data.findTask.assigned}
|
||||
onMemberChange={(member, isActive) => {
|
||||
if (isActive) {
|
||||
assignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
||||
} else {
|
||||
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onOpenAddLabelPopup={onOpenAddLabelPopup}
|
||||
onOpenAddChecklistPopup={(_task, $target) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup
|
||||
title={'Add checklist'}
|
||||
tab={0}
|
||||
onClose={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
<CreateChecklistPopup
|
||||
onCreateChecklist={checklistData => {
|
||||
let position = 65535;
|
||||
console.log(data.findTask.checklists);
|
||||
if (data.findTask.checklists) {
|
||||
const [lastChecklist] = data.findTask.checklists.slice(-1);
|
||||
console.log(`lastCheclist ${lastChecklist}`);
|
||||
if (lastChecklist) {
|
||||
position = lastChecklist.position * 2 + 1;
|
||||
}
|
||||
}
|
||||
createTaskChecklist({
|
||||
variables: {
|
||||
taskID: data.findTask.id,
|
||||
name: checklistData.name,
|
||||
position,
|
||||
},
|
||||
});
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onDeleteChecklist={($target, checklistID) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}>
|
||||
<p>Deleting a checklist is permanent and there is no way to get it back.</p>
|
||||
<DeleteChecklistButton
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
deleteTaskChecklist({ variables: { taskChecklistID: checklistID } });
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
Delete Checklist
|
||||
</DeleteChecklistButton>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onOpenDueDatePopop={(task, $targetRef) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup
|
||||
title={'Change Due Date'}
|
||||
tab={0}
|
||||
onClose={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
<DueDateManager
|
||||
task={task}
|
||||
onRemoveDueDate={t => {
|
||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
||||
hidePopup();
|
||||
}}
|
||||
onDueDateChange={(t, newDueDate) => {
|
||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||
hidePopup();
|
||||
}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Details;
|
6
frontend/src/Projects/Project/KanbanBoard/Styles.ts
Normal file
6
frontend/src/Projects/Project/KanbanBoard/Styles.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Board = styled.div`
|
||||
margin-top: 12px;
|
||||
margin-left: 8px;
|
||||
`;
|
31
frontend/src/Projects/Project/KanbanBoard/index.tsx
Normal file
31
frontend/src/Projects/Project/KanbanBoard/index.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { useRouteMatch, useHistory } from 'react-router';
|
||||
|
||||
import Lists from 'shared/components/Lists';
|
||||
import { Board } from './Styles';
|
||||
|
||||
type KanbanBoardProps = {
|
||||
onOpenListActionsPopup: ($targetRef: React.RefObject<HTMLElement>, taskGroupID: string) => void;
|
||||
onCardDrop: (task: Task) => void;
|
||||
onListDrop: (taskGroup: TaskGroup) => void;
|
||||
onCardCreate: (taskGroupID: string, name: string) => void;
|
||||
onQuickEditorOpen: (e: ContextMenuEvent) => void;
|
||||
onCreateList: (listName: string) => void;
|
||||
onCardMemberClick: OnCardMemberClick;
|
||||
};
|
||||
|
||||
const KanbanBoard: React.FC<KanbanBoardProps> = ({
|
||||
onOpenListActionsPopup,
|
||||
onQuickEditorOpen,
|
||||
onCardCreate,
|
||||
onCardDrop,
|
||||
onListDrop,
|
||||
onCreateList,
|
||||
onCardMemberClick,
|
||||
}) => {
|
||||
const match = useRouteMatch();
|
||||
const history = useHistory();
|
||||
return <Board></Board>;
|
||||
};
|
||||
|
||||
export default KanbanBoard;
|
907
frontend/src/Projects/Project/index.tsx
Normal file
907
frontend/src/Projects/Project/index.tsx
Normal file
@ -0,0 +1,907 @@
|
||||
// LOC830
|
||||
import React, { useState, useRef, useContext, useEffect } from 'react';
|
||||
import { MENU_TYPES } from 'shared/components/TopNavbar';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
useSetProjectOwnerMutation,
|
||||
useUpdateProjectMemberRoleMutation,
|
||||
useCreateProjectMemberMutation,
|
||||
useDeleteProjectMemberMutation,
|
||||
useSetTaskCompleteMutation,
|
||||
useToggleTaskLabelMutation,
|
||||
useUpdateProjectNameMutation,
|
||||
useFindProjectQuery,
|
||||
useUpdateTaskGroupNameMutation,
|
||||
useUpdateTaskNameMutation,
|
||||
useUpdateProjectLabelMutation,
|
||||
useCreateTaskMutation,
|
||||
useDeleteProjectLabelMutation,
|
||||
useDeleteTaskMutation,
|
||||
useUpdateTaskLocationMutation,
|
||||
useUpdateTaskGroupLocationMutation,
|
||||
useCreateTaskGroupMutation,
|
||||
useDeleteTaskGroupMutation,
|
||||
useUpdateTaskDescriptionMutation,
|
||||
useAssignTaskMutation,
|
||||
DeleteTaskDocument,
|
||||
FindProjectDocument,
|
||||
useCreateProjectLabelMutation,
|
||||
useUnassignTaskMutation,
|
||||
useUpdateTaskDueDateMutation,
|
||||
FindProjectQuery,
|
||||
useUsersQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import QuickCardEditor from 'shared/components/QuickCardEditor';
|
||||
import ListActions from 'shared/components/ListActions';
|
||||
import MemberManager from 'shared/components/MemberManager';
|
||||
import { LabelsPopup } from 'shared/components/PopupMenu/PopupMenu.stories';
|
||||
import KanbanBoard from 'Projects/Project/KanbanBoard';
|
||||
import SimpleLists from 'shared/components/Lists';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import LabelManager from 'shared/components/PopupMenu/LabelManager';
|
||||
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
|
||||
import produce from 'immer';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import Details from './Details';
|
||||
import { useApolloClient } from '@apollo/react-hooks';
|
||||
import UserIDContext from 'App/context';
|
||||
import DueDateManager from 'shared/components/DueDateManager';
|
||||
import Input from 'shared/components/Input';
|
||||
import Member from 'shared/components/Member';
|
||||
|
||||
const SearchInput = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const UserMember = styled(Member)`
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
||||
}
|
||||
border-radius: 6px;
|
||||
`;
|
||||
|
||||
const MemberList = styled.div`
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
type UserManagementPopupProps = {
|
||||
users: Array<User>;
|
||||
projectMembers: Array<TaskUser>;
|
||||
onAddProjectMember: (userID: string) => void;
|
||||
};
|
||||
|
||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => {
|
||||
return (
|
||||
<Popup tab={0} title="Invite a user">
|
||||
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
|
||||
<MemberList>
|
||||
{users
|
||||
.filter(u => u.id !== projectMembers.find(p => p.id === u.id)?.id)
|
||||
.map(user => (
|
||||
<UserMember
|
||||
key={user.id}
|
||||
onCardMemberClick={() => onAddProjectMember(user.id)}
|
||||
showName
|
||||
member={user}
|
||||
taskID=""
|
||||
/>
|
||||
))}
|
||||
</MemberList>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskRouteProps = {
|
||||
taskID: string;
|
||||
};
|
||||
|
||||
interface QuickCardEditorState {
|
||||
isOpen: boolean;
|
||||
target: React.RefObject<HTMLElement> | null;
|
||||
taskID: string | null;
|
||||
taskGroupID: string | null;
|
||||
}
|
||||
|
||||
const TitleWrapper = styled.div`
|
||||
margin-left: 38px;
|
||||
margin-bottom: 15px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
`;
|
||||
const ProjectMembers = styled.div`
|
||||
display: flex;
|
||||
padding-left: 4px;
|
||||
padding-top: 4px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
type LabelManagerEditorProps = {
|
||||
labels: React.RefObject<Array<ProjectLabel>>;
|
||||
taskLabels: null | React.RefObject<Array<TaskLabel>>;
|
||||
projectID: string;
|
||||
labelColors: Array<LabelColor>;
|
||||
onLabelToggle?: (labelId: string) => void;
|
||||
};
|
||||
|
||||
const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
|
||||
labels: labelsRef,
|
||||
projectID,
|
||||
labelColors,
|
||||
onLabelToggle,
|
||||
taskLabels: taskLabelsRef,
|
||||
}) => {
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const [createProjectLabel] = useCreateProjectLabelMutation({
|
||||
update: (client, newLabelData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
|
||||
}),
|
||||
{
|
||||
projectId: projectID,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateProjectLabel] = useUpdateProjectLabelMutation();
|
||||
const [deleteProjectLabel] = useDeleteProjectLabelMutation({
|
||||
update: (client, newLabelData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.labels = cache.findProject.labels.filter(
|
||||
label => label.id !== newLabelData.data.deleteProjectLabel.id,
|
||||
);
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const labels = labelsRef.current ? labelsRef.current : [];
|
||||
const taskLabels = taskLabelsRef && taskLabelsRef.current ? taskLabelsRef.current : [];
|
||||
const [currentTaskLabels, setCurrentTaskLabels] = useState(taskLabels);
|
||||
console.log(taskLabels);
|
||||
const { setTab, hidePopup } = usePopup();
|
||||
return (
|
||||
<>
|
||||
<Popup title="Labels" tab={0} onClose={() => hidePopup()}>
|
||||
<LabelManager
|
||||
labels={labels}
|
||||
taskLabels={currentTaskLabels}
|
||||
onLabelCreate={() => {
|
||||
setTab(2);
|
||||
}}
|
||||
onLabelEdit={labelId => {
|
||||
setCurrentLabel(labelId);
|
||||
setTab(1);
|
||||
}}
|
||||
onLabelToggle={labelId => {
|
||||
if (onLabelToggle) {
|
||||
if (currentTaskLabels.find(t => t.projectLabel.id === labelId)) {
|
||||
setCurrentTaskLabels(currentTaskLabels.filter(t => t.projectLabel.id !== labelId));
|
||||
} else {
|
||||
const newProjectLabel = labels.find(l => l.id === labelId);
|
||||
if (newProjectLabel) {
|
||||
setCurrentTaskLabels([
|
||||
...currentTaskLabels,
|
||||
{ id: '', assignedDate: '', projectLabel: { ...newProjectLabel } },
|
||||
]);
|
||||
}
|
||||
}
|
||||
setCurrentLabel(labelId);
|
||||
onLabelToggle(labelId);
|
||||
} else {
|
||||
setCurrentLabel(labelId);
|
||||
setTab(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup onClose={() => hidePopup()} title="Edit label" tab={1}>
|
||||
<LabelEditor
|
||||
labelColors={labelColors}
|
||||
label={labels.find(label => label.id === currentLabel) ?? null}
|
||||
onLabelEdit={(projectLabelID, name, color) => {
|
||||
if (projectLabelID) {
|
||||
updateProjectLabel({ variables: { projectLabelID, labelColorID: color.id, name: name ?? '' } });
|
||||
}
|
||||
setTab(0);
|
||||
}}
|
||||
onLabelDelete={labelID => {
|
||||
deleteProjectLabel({ variables: { projectLabelID: labelID } });
|
||||
setTab(0);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup onClose={() => hidePopup()} title="Create new label" tab={2}>
|
||||
<LabelEditor
|
||||
labelColors={labelColors}
|
||||
label={null}
|
||||
onLabelEdit={(_labelId, name, color) => {
|
||||
createProjectLabel({ variables: { projectID, labelColorID: color.id, name: name ?? '' } });
|
||||
setTab(0);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ProjectParams {
|
||||
projectID: string;
|
||||
}
|
||||
|
||||
const initialQuickCardEditorState: QuickCardEditorState = {
|
||||
taskID: null,
|
||||
taskGroupID: null,
|
||||
isOpen: false,
|
||||
target: null,
|
||||
};
|
||||
|
||||
const ProjectBar = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
`;
|
||||
|
||||
const ProjectActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ProjectAction = styled.div<{ disabled?: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
}
|
||||
${props =>
|
||||
props.disabled &&
|
||||
css`
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ProjectActionText = styled.span`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
const Project = () => {
|
||||
const { projectID } = useParams<ProjectParams>();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
|
||||
const [updateTaskDescription] = useUpdateTaskDescriptionMutation();
|
||||
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
|
||||
const [updateTaskLocation] = useUpdateTaskLocationMutation({
|
||||
update: (client, newTask) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
|
||||
if (previousTaskGroupID !== task.taskGroup.id) {
|
||||
const { taskGroups } = cache.findProject;
|
||||
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
|
||||
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
|
||||
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
|
||||
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
|
||||
(t: Task) => t.id !== task.id,
|
||||
);
|
||||
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
|
||||
...taskGroups[newTaskGroupIdx].tasks,
|
||||
{ ...task },
|
||||
];
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
|
||||
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
||||
|
||||
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
|
||||
onCompleted: deletedTaskGroupData => {},
|
||||
update: (client, deletedTaskGroupData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
|
||||
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
|
||||
);
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [createTaskGroup] = useCreateTaskGroupMutation({
|
||||
onCompleted: newTaskGroupData => {},
|
||||
update: (client, newTaskGroupData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache => {
|
||||
console.log(cache);
|
||||
return produce(cache, draftCache => {
|
||||
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
|
||||
});
|
||||
},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [createTask] = useCreateTaskMutation({
|
||||
onCompleted: newTaskData => {},
|
||||
update: (client, newTaskData) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const { taskGroups } = cache.findProject;
|
||||
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
|
||||
if (idx !== -1) {
|
||||
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
|
||||
}
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteTask] = useDeleteTaskMutation({
|
||||
onCompleted: deletedTask => {},
|
||||
});
|
||||
|
||||
const [updateTaskName] = useUpdateTaskNameMutation({
|
||||
onCompleted: newTaskData => {},
|
||||
});
|
||||
const [toggleTaskLabel] = useToggleTaskLabelMutation({
|
||||
onCompleted: newTaskLabel => {
|
||||
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
|
||||
console.log(taskLabelsRef.current);
|
||||
},
|
||||
});
|
||||
const { loading, data, refetch } = useFindProjectQuery({
|
||||
variables: { projectId: projectID },
|
||||
onCompleted: newData => {},
|
||||
});
|
||||
|
||||
const onCardCreate = (taskGroupID: string, name: string) => {
|
||||
if (data) {
|
||||
const taskGroupTasks = data.findProject.taskGroups.filter(t => t.id === taskGroupID);
|
||||
if (taskGroupTasks) {
|
||||
let position = 65535;
|
||||
if (taskGroupTasks.length !== 0) {
|
||||
const [lastTask] = taskGroupTasks.sort((a: any, b: any) => a.position - b.position).slice(-1);
|
||||
position = Math.ceil(lastTask.position) * 2 + 1;
|
||||
}
|
||||
|
||||
createTask({ variables: { taskGroupID, name, position } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateTask = (taskGroupID: string, name: string) => {
|
||||
if (data) {
|
||||
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
|
||||
console.log(`taskGroup ${taskGroup}`);
|
||||
if (taskGroup) {
|
||||
let position = 65535;
|
||||
if (taskGroup.tasks.length !== 0) {
|
||||
const [lastTask] = taskGroup.tasks
|
||||
.slice()
|
||||
.sort((a: any, b: any) => a.position - b.position)
|
||||
.slice(-1);
|
||||
position = Math.ceil(lastTask.position) * 2 + 1;
|
||||
}
|
||||
|
||||
console.log(`position ${position}`);
|
||||
createTask({
|
||||
variables: { taskGroupID, name, position },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
createTask: {
|
||||
__typename: 'Task',
|
||||
id: '' + Math.round(Math.random() * -1000000),
|
||||
name,
|
||||
complete: false,
|
||||
taskGroup: {
|
||||
__typename: 'TaskGroup',
|
||||
id: taskGroup.id,
|
||||
name: taskGroup.name,
|
||||
position: taskGroup.position,
|
||||
},
|
||||
badges: {
|
||||
checklist: null,
|
||||
},
|
||||
position,
|
||||
dueDate: null,
|
||||
description: null,
|
||||
labels: [],
|
||||
assigned: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onListDrop = (droppedColumn: TaskGroup) => {
|
||||
console.log(`list drop ${droppedColumn.id}`);
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const taskGroupIdx = cache.findProject.taskGroups.findIndex(t => t.id === droppedColumn.id);
|
||||
if (taskGroupIdx !== -1) {
|
||||
draftCache.findProject.taskGroups[taskGroupIdx].position = droppedColumn.position;
|
||||
}
|
||||
}),
|
||||
{
|
||||
projectId: projectID,
|
||||
},
|
||||
);
|
||||
updateTaskGroupLocation({
|
||||
variables: { taskGroupID: droppedColumn.id, position: droppedColumn.position },
|
||||
optimisticResponse: {
|
||||
updateTaskGroupLocation: {
|
||||
id: droppedColumn.id,
|
||||
position: droppedColumn.position,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateList = (listName: string) => {
|
||||
if (data) {
|
||||
const [lastColumn] = data.findProject.taskGroups.sort((a, b) => a.position - b.position).slice(-1);
|
||||
let position = 65535;
|
||||
if (lastColumn) {
|
||||
position = lastColumn.position * 2 + 1;
|
||||
}
|
||||
createTaskGroup({ variables: { projectID, name: listName, position } });
|
||||
}
|
||||
};
|
||||
|
||||
const [assignTask] = useAssignTaskMutation();
|
||||
const [unassignTask] = useUnassignTaskMutation();
|
||||
|
||||
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
|
||||
|
||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
|
||||
|
||||
const [updateProjectName] = useUpdateProjectNameMutation({
|
||||
update: (client, newName) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.name = newName.data.updateProjectName.name;
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
||||
const [createProjectMember] = useCreateProjectMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [setProjectOwner] = useSetProjectOwnerMutation();
|
||||
const [deleteProjectMember] = useDeleteProjectMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.members = cache.findProject.members.filter(
|
||||
m => m.id !== response.data.deleteProjectMember.member.id,
|
||||
);
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const client = useApolloClient();
|
||||
const { userID } = useContext(UserIDContext);
|
||||
const location = useLocation();
|
||||
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
document.title = `${data.findProject.name} | Citadel`;
|
||||
}
|
||||
}, [data]);
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar onSaveProjectName={projectName => {}} name="" projectID={null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (data) {
|
||||
console.log(data.findProject);
|
||||
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
|
||||
if ($target && $target.current) {
|
||||
const pos = $target.current.getBoundingClientRect();
|
||||
const height = 120;
|
||||
if (window.innerHeight - pos.bottom < height) {
|
||||
}
|
||||
}
|
||||
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
|
||||
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null;
|
||||
if (currentTask) {
|
||||
setQuickCardEditor({
|
||||
target: $target,
|
||||
isOpen: true,
|
||||
taskID: currentTask.id,
|
||||
taskGroupID: currentTask.taskGroup.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
labelsRef.current = data.findProject.labels;
|
||||
|
||||
let currentQuickTask = null;
|
||||
if (quickCardEditor.taskID && quickCardEditor.taskGroupID) {
|
||||
const targetGroup = data.findProject.taskGroups.find(t => t.id === quickCardEditor.taskGroupID);
|
||||
if (targetGroup) {
|
||||
currentQuickTask = targetGroup.tasks.find(t => t.id === quickCardEditor.taskID);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar
|
||||
onChangeRole={(userID, roleCode) => {
|
||||
updateProjectMemberRole({ variables: { userID, roleCode, projectID } });
|
||||
}}
|
||||
onChangeProjectOwner={uid => {
|
||||
setProjectOwner({ variables: { ownerID: uid, projectID } });
|
||||
hidePopup();
|
||||
}}
|
||||
onRemoveFromBoard={userID => {
|
||||
deleteProjectMember({ variables: { userID, projectID } });
|
||||
hidePopup();
|
||||
}}
|
||||
onSaveProjectName={projectName => {
|
||||
updateProjectName({ variables: { projectID, name: projectName } });
|
||||
}}
|
||||
onInviteUser={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<UserManagementPopup
|
||||
onAddProjectMember={userID => {
|
||||
createProjectMember({ variables: { userID, projectID } });
|
||||
}}
|
||||
users={data.users}
|
||||
projectMembers={data.findProject.members}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />}
|
||||
menuType={[{ name: 'Board', link: location.pathname }]}
|
||||
currentTab={0}
|
||||
projectMembers={data.findProject.members}
|
||||
projectID={projectID}
|
||||
name={data.findProject.name}
|
||||
/>
|
||||
<ProjectBar>
|
||||
<ProjectActions>
|
||||
<ProjectAction disabled>
|
||||
<CheckCircle width={13} height={13} />
|
||||
<ProjectActionText>All Tasks</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<Filter width={13} height={13} />
|
||||
<ProjectActionText>Filter</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<Sort width={13} height={13} />
|
||||
<ProjectActionText>Sort</ProjectActionText>
|
||||
</ProjectAction>
|
||||
</ProjectActions>
|
||||
<ProjectActions>
|
||||
<ProjectAction
|
||||
ref={$labelsRef}
|
||||
onClick={() => {
|
||||
showPopup(
|
||||
$labelsRef,
|
||||
<LabelManagerEditor
|
||||
taskLabels={null}
|
||||
labelColors={data.labelColors}
|
||||
labels={labelsRef}
|
||||
projectID={projectID}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Tags width={13} height={13} />
|
||||
<ProjectActionText>Labels</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<ToggleOn width={13} height={13} />
|
||||
<ProjectActionText>Fields</ProjectActionText>
|
||||
</ProjectAction>
|
||||
<ProjectAction disabled>
|
||||
<Bolt width={13} height={13} />
|
||||
<ProjectActionText>Rules</ProjectActionText>
|
||||
</ProjectAction>
|
||||
</ProjectActions>
|
||||
</ProjectBar>
|
||||
<SimpleLists
|
||||
onTaskClick={task => {
|
||||
history.push(`${match.url}/c/${task.id}`);
|
||||
}}
|
||||
onTaskDrop={(droppedTask, previousTaskGroupID) => {
|
||||
updateTaskLocation({
|
||||
variables: {
|
||||
taskID: droppedTask.id,
|
||||
taskGroupID: droppedTask.taskGroup.id,
|
||||
position: droppedTask.position,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskLocation: {
|
||||
previousTaskGroupID,
|
||||
task: {
|
||||
name: droppedTask.name,
|
||||
id: droppedTask.id,
|
||||
position: droppedTask.position,
|
||||
taskGroup: {
|
||||
id: droppedTask.taskGroup.id,
|
||||
__typename: 'TaskGroup',
|
||||
},
|
||||
createdAt: '',
|
||||
__typename: 'Task',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onTaskGroupDrop={droppedTaskGroup => {
|
||||
updateTaskGroupLocation({
|
||||
variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskGroupLocation: {
|
||||
id: droppedTaskGroup.id,
|
||||
position: droppedTaskGroup.position,
|
||||
__typename: 'TaskGroup',
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
taskGroups={data.findProject.taskGroups}
|
||||
onCreateTask={onCreateTask}
|
||||
onCreateTaskGroup={onCreateList}
|
||||
onCardMemberClick={($targetRef, taskID, memberID) => {
|
||||
const member = data.findProject.members.find(m => m.id === memberID);
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<MiniProfile
|
||||
user={member}
|
||||
bio="None"
|
||||
onRemoveFromTask={() => {
|
||||
/* unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); */
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChangeTaskGroupName={(taskGroupID, name) => {
|
||||
updateTaskGroupName({ variables: { taskGroupID, name } });
|
||||
}}
|
||||
onQuickEditorOpen={onQuickEditorOpen}
|
||||
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title="List actions" tab={0} onClose={() => hidePopup()}>
|
||||
<ListActions
|
||||
taskGroupID={taskGroupID}
|
||||
onArchiveTaskGroup={tgID => {
|
||||
deleteTaskGroup({ variables: { taskGroupID: tgID } });
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{quickCardEditor.isOpen && currentQuickTask && quickCardEditor.target && (
|
||||
<QuickCardEditor
|
||||
task={currentQuickTask}
|
||||
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
|
||||
onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => {
|
||||
updateTaskName({ variables: { taskID, name: cardName } });
|
||||
}}
|
||||
onOpenMembersPopup={($targetRef, task) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title="Members" tab={0} onClose={() => hidePopup()}>
|
||||
<MemberManager
|
||||
availableMembers={data.findProject.members}
|
||||
activeMembers={task.assigned ?? []}
|
||||
onMemberChange={(member, isActive) => {
|
||||
if (isActive) {
|
||||
assignTask({ variables: { taskID: task.id, userID: userID ?? '' } });
|
||||
} else {
|
||||
unassignTask({ variables: { taskID: task.id, userID: userID ?? '' } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onCardMemberClick={($targetRef, taskID, memberID) => {
|
||||
const member = data.findProject.members.find(m => m.id === memberID);
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<MiniProfile
|
||||
bio="None"
|
||||
user={member}
|
||||
onRemoveFromTask={() => {
|
||||
/* unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); */
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onOpenLabelsPopup={($targetRef, task) => {
|
||||
taskLabelsRef.current = task.labels;
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<LabelManagerEditor
|
||||
onLabelToggle={labelID => {
|
||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||
}}
|
||||
labelColors={data.labelColors}
|
||||
labels={labelsRef}
|
||||
taskLabels={taskLabelsRef}
|
||||
projectID={projectID}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
onArchiveCard={(_listId: string, cardId: string) =>
|
||||
deleteTask({
|
||||
variables: { taskID: cardId },
|
||||
update: () => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.taskGroups = cache.findProject.taskGroups.map(taskGroup => ({
|
||||
...taskGroup,
|
||||
tasks: taskGroup.tasks.filter(t => t.id !== cardId),
|
||||
}));
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
})
|
||||
}
|
||||
onOpenDueDatePopup={($targetRef, task) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title={'Change Due Date'} tab={0} onClose={() => hidePopup()}>
|
||||
<DueDateManager
|
||||
task={task}
|
||||
onRemoveDueDate={t => {
|
||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
||||
hidePopup();
|
||||
}}
|
||||
onDueDateChange={(t, newDueDate) => {
|
||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||
hidePopup();
|
||||
}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onToggleComplete={task => {
|
||||
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
||||
}}
|
||||
target={quickCardEditor.target}
|
||||
/>
|
||||
)}
|
||||
<Route
|
||||
path={`${match.path}/c/:taskID`}
|
||||
render={(routeProps: RouteComponentProps<TaskRouteProps>) => (
|
||||
<Details
|
||||
refreshCache={() => {}}
|
||||
availableMembers={data.findProject.members}
|
||||
projectURL={match.url}
|
||||
taskID={routeProps.match.params.taskID}
|
||||
onTaskNameChange={(updatedTask, newName) => {
|
||||
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
|
||||
}}
|
||||
onTaskDescriptionChange={(updatedTask, newDescription) => {
|
||||
updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } });
|
||||
}}
|
||||
onDeleteTask={deletedTask => {
|
||||
deleteTask({ variables: { taskID: deletedTask.id } });
|
||||
}}
|
||||
onOpenAddLabelPopup={(task, $targetRef) => {
|
||||
taskLabelsRef.current = task.labels;
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<LabelManagerEditor
|
||||
onLabelToggle={labelID => {
|
||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||
}}
|
||||
labelColors={data.labelColors}
|
||||
labels={labelsRef}
|
||||
taskLabels={taskLabelsRef}
|
||||
projectID={projectID}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <div>Error</div>;
|
||||
};
|
||||
|
||||
export default Project;
|
405
frontend/src/Projects/index.tsx
Normal file
405
frontend/src/Projects/index.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import Empty from 'shared/undraw/Empty';
|
||||
import {
|
||||
useCreateTeamMutation,
|
||||
useGetProjectsQuery,
|
||||
useCreateProjectMutation,
|
||||
GetProjectsDocument,
|
||||
GetProjectsQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
|
||||
import ProjectGridItem, { AddProjectItem } from 'shared/components/ProjectGridItem';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Navbar from 'App/Navbar';
|
||||
import NewProject from 'shared/components/NewProject';
|
||||
import UserIDContext from 'App/context';
|
||||
import Button from 'shared/components/Button';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import Input from 'shared/components/Input';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
const EmptyStateContent = styled.div`
|
||||
display: flex;
|
||||
justy-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const EmptyStateTitle = styled.h3`
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
`;
|
||||
|
||||
const EmptyStatePrompt = styled.span`
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
const EmptyState = styled(Empty)`
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
const CreateTeamButton = styled(Button)`
|
||||
width: 100%;
|
||||
`;
|
||||
type CreateTeamData = { teamName: string };
|
||||
type CreateTeamFormProps = {
|
||||
onCreateTeam: (teamName: string) => void;
|
||||
};
|
||||
const CreateTeamFormContainer = styled.form``;
|
||||
|
||||
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
|
||||
const { register, handleSubmit, errors } = useForm<CreateTeamData>();
|
||||
const createTeam = (data: CreateTeamData) => {
|
||||
onCreateTeam(data.teamName);
|
||||
};
|
||||
return (
|
||||
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
|
||||
<Input
|
||||
width="100%"
|
||||
label="Team name"
|
||||
id="teamName"
|
||||
name="teamName"
|
||||
variant="alternate"
|
||||
ref={register({ required: 'Team name is required' })}
|
||||
/>
|
||||
<CreateTeamButton type="submit">Create</CreateTeamButton>
|
||||
</CreateTeamFormContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectAddTile = styled.div`
|
||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const ProjectTile = styled(Link)<{ color: string }>`
|
||||
background-color: ${props => props.color};
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const ProjectTileFade = styled.div`
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const ProjectListItem = styled.li`
|
||||
width: 23.5%;
|
||||
padding: 0;
|
||||
margin: 0 2% 2% 0;
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover ${ProjectTileFade} {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectList = styled.ul`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& ${ProjectListItem}:nth-of-type(4n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectTileDetails = styled.div`
|
||||
display: flex;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ProjectAddTileDetails = styled.div`
|
||||
display: flex;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ProjectTileName = styled.div<{ centered?: boolean }>`
|
||||
flex: 0 0 auto;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
max-height: 40px;
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
${props => props.centered && 'text-align: center;'}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ProjectSectionTitleWrapper = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
margin-bottom: 24px;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
const SectionActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SectionAction = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
const SectionActionLink = styled(Link)`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const ProjectSectionTitle = styled.h3`
|
||||
font-size: 16px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
||||
|
||||
const ProjectsContainer = styled.div`
|
||||
margin: 40px 16px 0;
|
||||
width: 100%;
|
||||
max-width: 825px;
|
||||
min-width: 288px;
|
||||
`;
|
||||
const ProjectGrid = styled.div`
|
||||
max-width: 780px;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 240px 240px;
|
||||
gap: 20px 10px;
|
||||
`;
|
||||
const AddTeamButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 12px;
|
||||
`;
|
||||
|
||||
const CreateFirstTeam = styled(Button)`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
type ShowNewProject = {
|
||||
open: boolean;
|
||||
initialTeamID: null | string;
|
||||
};
|
||||
|
||||
const ProjectLink = styled(Link)``;
|
||||
|
||||
const Projects = () => {
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const { loading, data } = useGetProjectsQuery();
|
||||
useEffect(() => {
|
||||
document.title = 'Citadel';
|
||||
}, []);
|
||||
const [createProject] = useCreateProjectMutation({
|
||||
update: (client, newProject) => {
|
||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.projects.push({ ...newProject.data.createProject });
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null });
|
||||
const { userID, setUserID } = useContext(UserIDContext);
|
||||
const [createTeam] = useCreateTeamMutation({
|
||||
update: (client, createData) => {
|
||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.teams.push({ ...createData.data.createTeam });
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<span>loading</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
|
||||
if (data) {
|
||||
const { projects, teams, organizations } = data;
|
||||
const organizationID = organizations[0].id ?? null;
|
||||
const projectTeams = teams.map(team => {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
projects: projects.filter(project => project.team.id === team.id),
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar onSaveProjectName={() => {}} projectID={null} name={null} />
|
||||
<Wrapper>
|
||||
<ProjectsContainer>
|
||||
<AddTeamButton
|
||||
variant="outline"
|
||||
onClick={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup
|
||||
title="Create team"
|
||||
tab={0}
|
||||
onClose={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
<CreateTeamForm
|
||||
onCreateTeam={teamName => {
|
||||
if (organizationID) {
|
||||
createTeam({ variables: { name: teamName, organizationID } });
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Team
|
||||
</AddTeamButton>
|
||||
{projectTeams.length === 0 && (
|
||||
<EmptyStateContent>
|
||||
<EmptyState width={425} height={425} />
|
||||
<EmptyStateTitle>No teams exist</EmptyStateTitle>
|
||||
<EmptyStatePrompt>Create a new team to get started</EmptyStatePrompt>
|
||||
<CreateFirstTeam
|
||||
variant="outline"
|
||||
onClick={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup
|
||||
title="Create team"
|
||||
tab={0}
|
||||
onClose={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
>
|
||||
<CreateTeamForm
|
||||
onCreateTeam={teamName => {
|
||||
if (organizationID) {
|
||||
createTeam({ variables: { name: teamName, organizationID } });
|
||||
hidePopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create new team
|
||||
</CreateFirstTeam>
|
||||
</EmptyStateContent>
|
||||
)}
|
||||
{projectTeams.map(team => {
|
||||
return (
|
||||
<div key={team.id}>
|
||||
<ProjectSectionTitleWrapper>
|
||||
<ProjectSectionTitle>{team.name}</ProjectSectionTitle>
|
||||
<SectionActions>
|
||||
<SectionActionLink to={`/teams/${team.id}`}>
|
||||
<SectionAction variant="outline">Projects</SectionAction>
|
||||
</SectionActionLink>
|
||||
<SectionActionLink to={`/teams/${team.id}/members`}>
|
||||
<SectionAction variant="outline">Members</SectionAction>
|
||||
</SectionActionLink>
|
||||
<SectionActionLink to={`/teams/${team.id}/settings`}>
|
||||
<SectionAction variant="outline">Settings</SectionAction>
|
||||
</SectionActionLink>
|
||||
</SectionActions>
|
||||
</ProjectSectionTitleWrapper>
|
||||
<ProjectList>
|
||||
{team.projects.map((project, idx) => (
|
||||
<ProjectListItem key={project.id}>
|
||||
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
|
||||
<ProjectTileFade />
|
||||
<ProjectTileDetails>
|
||||
<ProjectTileName>{project.name}</ProjectTileName>
|
||||
</ProjectTileDetails>
|
||||
</ProjectTile>
|
||||
</ProjectListItem>
|
||||
))}
|
||||
<ProjectListItem>
|
||||
<ProjectAddTile
|
||||
onClick={() => {
|
||||
setShowNewProject({ open: true, initialTeamID: team.id });
|
||||
}}
|
||||
>
|
||||
<ProjectTileFade />
|
||||
<ProjectAddTileDetails>
|
||||
<ProjectTileName centered>Create new project</ProjectTileName>
|
||||
</ProjectAddTileDetails>
|
||||
</ProjectAddTile>
|
||||
</ProjectListItem>
|
||||
</ProjectList>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showNewProject.open && (
|
||||
<NewProject
|
||||
initialTeamID={showNewProject.initialTeamID}
|
||||
onCreateProject={(name, teamID) => {
|
||||
if (userID) {
|
||||
createProject({ variables: { teamID, name, userID } });
|
||||
setShowNewProject({ open: false, initialTeamID: null });
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowNewProject({ open: false, initialTeamID: null });
|
||||
}}
|
||||
teams={teams}
|
||||
/>
|
||||
)}
|
||||
</ProjectsContainer>
|
||||
</Wrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <div>Error!</div>;
|
||||
};
|
||||
|
||||
export default Projects;
|
544
frontend/src/Teams/Members/index.tsx
Normal file
544
frontend/src/Teams/Members/index.tsx
Normal file
@ -0,0 +1,544 @@
|
||||
import React from 'react';
|
||||
import Input from 'shared/components/Input';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
import Button from 'shared/components/Button';
|
||||
import {
|
||||
useGetTeamQuery,
|
||||
RoleCode,
|
||||
useCreateTeamMemberMutation,
|
||||
useDeleteTeamMemberMutation,
|
||||
GetTeamQuery,
|
||||
GetTeamDocument,
|
||||
} from 'shared/generated/graphql';
|
||||
import { UserPlus, Checkmark } from 'shared/icons';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import Member from 'shared/components/Member';
|
||||
|
||||
const MemberListWrapper = styled.div`
|
||||
flex: 1 1;
|
||||
`;
|
||||
|
||||
const SearchInput = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const UserMember = styled(Member)`
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
||||
}
|
||||
border-radius: 6px;
|
||||
`;
|
||||
|
||||
const TeamMemberList = styled.div`
|
||||
margin: 8px 0;
|
||||
`;
|
||||
|
||||
type UserManagementPopupProps = {
|
||||
users: Array<User>;
|
||||
teamMembers: Array<TaskUser>;
|
||||
onAddTeamMember: (userID: string) => void;
|
||||
};
|
||||
|
||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, teamMembers, onAddTeamMember }) => {
|
||||
return (
|
||||
<Popup tab={0} title="Invite a user">
|
||||
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
|
||||
<TeamMemberList>
|
||||
{users
|
||||
.filter(u => u.id !== teamMembers.find(p => p.id === u.id)?.id)
|
||||
.map(user => (
|
||||
<UserMember
|
||||
key={user.id}
|
||||
onCardMemberClick={() => onAddTeamMember(user.id)}
|
||||
showName
|
||||
member={user}
|
||||
taskID=""
|
||||
/>
|
||||
))}
|
||||
</TeamMemberList>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoleCheckmark = styled(Checkmark)`
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
const permissions = [
|
||||
{
|
||||
code: 'owner',
|
||||
name: 'Owner',
|
||||
description:
|
||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.',
|
||||
},
|
||||
{
|
||||
code: 'admin',
|
||||
name: 'Admin',
|
||||
description:
|
||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.',
|
||||
},
|
||||
|
||||
{ code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' },
|
||||
];
|
||||
|
||||
export const RoleName = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
export const RoleDescription = styled.div`
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
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 Content = styled.div`
|
||||
padding: 0 12px 12px;
|
||||
`;
|
||||
|
||||
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%;
|
||||
`;
|
||||
type TeamRoleManagerPopupProps = {
|
||||
user: TaskUser;
|
||||
warning?: string | null;
|
||||
canChangeRole: boolean;
|
||||
onChangeRole: (roleCode: RoleCode) => void;
|
||||
onRemoveFromTeam?: () => void;
|
||||
onChangeTeamOwner?: (userID: string) => void;
|
||||
};
|
||||
|
||||
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||
warning,
|
||||
user,
|
||||
canChangeRole,
|
||||
onRemoveFromTeam,
|
||||
onChangeTeamOwner,
|
||||
onChangeRole,
|
||||
}) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} tab={0}>
|
||||
<MiniProfileActions>
|
||||
<MiniProfileActionWrapper>
|
||||
{onChangeTeamOwner && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(3);
|
||||
}}
|
||||
>
|
||||
Set as team owner...
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
{user.role && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(1);
|
||||
}}
|
||||
>
|
||||
Change permissions...
|
||||
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
{onRemoveFromTeam && (
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(2);
|
||||
}}
|
||||
>
|
||||
Remove from team...
|
||||
</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;
|
||||
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 from Team?" 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 (onRemoveFromTeam) {
|
||||
onRemoveFromTeam();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove Member
|
||||
</RemoveMemberButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
<Popup title="Set as Team 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 (onChangeTeamOwner) {
|
||||
onChangeTeamOwner(user.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Set as Project Owner
|
||||
</RemoveMemberButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberItemOptions = styled.div``;
|
||||
|
||||
const MemberItemOption = styled(Button)`
|
||||
padding: 7px 9px;
|
||||
margin: 4px 0 2px 8px;
|
||||
float: left;
|
||||
min-width: 95px;
|
||||
`;
|
||||
|
||||
const MemberList = styled.div`
|
||||
border-top: 1px solid rgba(${props => props.theme.colors.border});
|
||||
`;
|
||||
|
||||
const MemberListItem = styled.div`
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
|
||||
min-height: 40px;
|
||||
padding: 12px 0 12px 40px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const MemberListItemDetails = styled.div`
|
||||
float: left;
|
||||
flex: 1 0 auto;
|
||||
padding-left: 8px;
|
||||
`;
|
||||
|
||||
const InviteIcon = styled(UserPlus)`
|
||||
padding-right: 4px;
|
||||
`;
|
||||
|
||||
const MemberProfile = styled(TaskAssignee)`
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const MemberItemName = styled.p`
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
`;
|
||||
|
||||
const MemberItemUsername = styled.p`
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
||||
|
||||
const MemberListHeader = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
const ListTitle = styled.h3`
|
||||
font-size: 18px;
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
const ListDesc = styled.span`
|
||||
font-size: 16px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
`;
|
||||
const FilterSearch = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ListActions = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 18px;
|
||||
`;
|
||||
|
||||
const InviteMemberButton = styled(Button)`
|
||||
padding: 6px 12px;
|
||||
`;
|
||||
const FilterTab = styled.div`
|
||||
max-width: 240px;
|
||||
flex: 0 0 240px;
|
||||
margin: 0;
|
||||
padding-right: 32px;
|
||||
`;
|
||||
|
||||
const FilterTabItems = styled.ul``;
|
||||
const FilterTabItem = styled.li`
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
padding: 6px 8px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
&:hover {
|
||||
border-radius: 6px;
|
||||
background: rgba(${props => props.theme.colors.primary});
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterTabTitle = styled.h2`
|
||||
color: #5e6c84;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 16px;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const MemberContainer = styled.div`
|
||||
margin-top: 45px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type MembersProps = {
|
||||
teamID: string;
|
||||
};
|
||||
|
||||
const Members: React.FC<MembersProps> = ({ teamID }) => {
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
|
||||
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”.';
|
||||
const [createTeamMember] = useCreateTeamMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<GetTeamQuery>(
|
||||
client,
|
||||
GetTeamDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findTeam.members.push({ ...response.data.createTeamMember.teamMember });
|
||||
}),
|
||||
{ teamID },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [deleteTeamMember] = useDeleteTeamMemberMutation({
|
||||
update: (client, response) => {
|
||||
updateApolloCache<GetTeamQuery>(
|
||||
client,
|
||||
GetTeamDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findTeam.members = cache.findTeam.members.filter(
|
||||
member => member.id !== response.data.deleteTeamMember.userID,
|
||||
);
|
||||
}),
|
||||
{ teamID },
|
||||
);
|
||||
},
|
||||
});
|
||||
if (loading) {
|
||||
return <span>loading</span>;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return (
|
||||
<MemberContainer>
|
||||
<FilterTab>
|
||||
<FilterTabTitle>MEMBERS OF TEAM PROJECTS</FilterTabTitle>
|
||||
<FilterTabItems>
|
||||
<FilterTabItem>{`Team Members (${data.findTeam.members.length})`}</FilterTabItem>
|
||||
<FilterTabItem>Observers</FilterTabItem>
|
||||
</FilterTabItems>
|
||||
</FilterTab>
|
||||
<MemberListWrapper>
|
||||
<MemberListHeader>
|
||||
<ListTitle>{`Team Members (${data.findTeam.members.length})`}</ListTitle>
|
||||
<ListDesc>
|
||||
Team members can view and join all Team Visible boards and create new boards in the team.
|
||||
</ListDesc>
|
||||
<ListActions>
|
||||
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
|
||||
<InviteMemberButton
|
||||
onClick={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<UserManagementPopup
|
||||
users={data.users}
|
||||
teamMembers={data.findTeam.members}
|
||||
onAddTeamMember={userID => {
|
||||
createTeamMember({ variables: { userID, teamID } });
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<InviteIcon width={16} height={16} />
|
||||
Invite Team Members
|
||||
</InviteMemberButton>
|
||||
</ListActions>
|
||||
</MemberListHeader>
|
||||
<MemberList>
|
||||
{data.findTeam.members.map(member => (
|
||||
<MemberListItem>
|
||||
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
|
||||
<MemberListItemDetails>
|
||||
<MemberItemName>{member.fullName}</MemberItemName>
|
||||
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
|
||||
</MemberListItemDetails>
|
||||
<MemberItemOptions>
|
||||
<MemberItemOption variant="flat">On 2 projects</MemberItemOption>
|
||||
<MemberItemOption
|
||||
variant="outline"
|
||||
onClick={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<TeamRoleManagerPopup
|
||||
user={member}
|
||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||
onChangeTeamOwner={
|
||||
member.role && member.role.code !== 'owner' ? (userID: string) => {} : undefined
|
||||
}
|
||||
canChangeRole={member.role && member.role.code !== 'owner'}
|
||||
onChangeRole={roleCode => {}}
|
||||
onRemoveFromTeam={
|
||||
member.role && member.role.code === 'owner'
|
||||
? undefined
|
||||
: () => {
|
||||
deleteTeamMember({ variables: { teamID, userID: member.id } });
|
||||
hidePopup();
|
||||
}
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</MemberItemOption>
|
||||
</MemberItemOptions>
|
||||
</MemberListItem>
|
||||
))}
|
||||
</MemberList>
|
||||
</MemberListWrapper>
|
||||
</MemberContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>error</div>;
|
||||
};
|
||||
|
||||
export default Members;
|
194
frontend/src/Teams/Projects/index.tsx
Normal file
194
frontend/src/Teams/Projects/index.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import {
|
||||
useGetTeamQuery,
|
||||
useDeleteTeamMutation,
|
||||
GetProjectsDocument,
|
||||
GetProjectsQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Input from 'shared/components/Input';
|
||||
|
||||
const FilterSearch = styled(Input)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ProjectsContainer = styled.div`
|
||||
margin-top: 45px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const FilterTab = styled.div`
|
||||
max-width: 240px;
|
||||
flex: 0 0 240px;
|
||||
margin: 0;
|
||||
padding-right: 32px;
|
||||
`;
|
||||
|
||||
const FilterTabItems = styled.ul``;
|
||||
const FilterTabItem = styled.li`
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
padding: 6px 8px;
|
||||
color: rgba(${props => props.theme.colors.text.primary});
|
||||
&:hover {
|
||||
border-radius: 6px;
|
||||
background: rgba(${props => props.theme.colors.primary});
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
}
|
||||
`;
|
||||
|
||||
const FilterTabTitle = styled.h2`
|
||||
color: #5e6c84;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 16px;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ProjectAddTile = styled.div`
|
||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const ProjectTile = styled(Link)<{ color: string }>`
|
||||
background-color: ${props => props.color};
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
color: #fff;
|
||||
line-height: 20px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const ProjectTileFade = styled.div`
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const ProjectListItem = styled.li`
|
||||
width: 23.5%;
|
||||
padding: 0;
|
||||
margin: 0 2% 2% 0;
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover ${ProjectTileFade} {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectList = styled.ul`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 16px;
|
||||
|
||||
& ${ProjectListItem}:nth-of-type(4n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ProjectTileDetails = styled.div`
|
||||
display: flex;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ProjectAddTileDetails = styled.div`
|
||||
display: flex;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const ProjectTileName = styled.div<{ centered?: boolean }>`
|
||||
flex: 0 0 auto;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
max-height: 40px;
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
${props => props.centered && 'text-align: center;'}
|
||||
`;
|
||||
const ProjectListWrapper = styled.div`
|
||||
flex: 1 1;
|
||||
`;
|
||||
|
||||
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
|
||||
|
||||
type TeamProjectsProps = {
|
||||
teamID: string;
|
||||
};
|
||||
|
||||
const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
|
||||
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
|
||||
if (loading) {
|
||||
return <span>loading</span>;
|
||||
}
|
||||
if (data) {
|
||||
return (
|
||||
<ProjectsContainer>
|
||||
<FilterTab>
|
||||
<FilterSearch placeholder="Search for projects..." width="100%" variant="alternate" />
|
||||
<FilterTabTitle>SORT</FilterTabTitle>
|
||||
<FilterTabItems>
|
||||
<FilterTabItem>Most Recently Active</FilterTabItem>
|
||||
<FilterTabItem>Number of Members</FilterTabItem>
|
||||
<FilterTabItem>Number of Stars</FilterTabItem>
|
||||
<FilterTabItem>Alphabetical</FilterTabItem>
|
||||
</FilterTabItems>
|
||||
</FilterTab>
|
||||
<ProjectListWrapper>
|
||||
<ProjectList>
|
||||
{data.projects.map((project, idx) => (
|
||||
<ProjectListItem key={project.id}>
|
||||
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
|
||||
<ProjectTileFade />
|
||||
<ProjectTileDetails>
|
||||
<ProjectTileName>{project.name}</ProjectTileName>
|
||||
</ProjectTileDetails>
|
||||
</ProjectTile>
|
||||
</ProjectListItem>
|
||||
))}
|
||||
</ProjectList>
|
||||
</ProjectListWrapper>
|
||||
</ProjectsContainer>
|
||||
);
|
||||
}
|
||||
return <span>error</span>;
|
||||
};
|
||||
|
||||
export default TeamProjects;
|
7
frontend/src/Teams/Settings/index.tsx
Normal file
7
frontend/src/Teams/Settings/index.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const TeamSettings = () => {
|
||||
return <h1>HI!</h1>;
|
||||
};
|
||||
|
||||
export default TeamSettings;
|
137
frontend/src/Teams/index.tsx
Normal file
137
frontend/src/Teams/index.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import { MENU_TYPES } from 'shared/components/TopNavbar';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import { Route, Switch, useRouteMatch } from 'react-router';
|
||||
import Members from './Members';
|
||||
import Projects from './Projects';
|
||||
|
||||
import {
|
||||
useGetTeamQuery,
|
||||
useDeleteTeamMutation,
|
||||
GetProjectsDocument,
|
||||
GetProjectsQuery,
|
||||
} from 'shared/generated/graphql';
|
||||
import { useParams, useHistory, useLocation } from 'react-router';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { History } from 'history';
|
||||
import produce from 'immer';
|
||||
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
||||
|
||||
const OuterWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type TeamPopupProps = {
|
||||
history: History<History.PoorMansUnknown>;
|
||||
name: string;
|
||||
teamID: string;
|
||||
};
|
||||
|
||||
export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
const [deleteTeam] = useDeleteTeamMutation({
|
||||
update: (client, deleteData) => {
|
||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data.deleteTeam.team.id);
|
||||
draftCache.projects = cache.projects.filter(
|
||||
(project: any) => project.team.id !== deleteData.data.deleteTeam.team.id,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} tab={0}>
|
||||
<TeamSettings
|
||||
onDeleteTeam={() => {
|
||||
setTab(1, 340);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup title={`Delete the "${name}" team?`} tab={1} onClose={() => hidePopup()}>
|
||||
<DeleteConfirm
|
||||
description={DELETE_INFO.DELETE_TEAMS.description}
|
||||
deletedItems={DELETE_INFO.DELETE_TEAMS.deletedItems}
|
||||
onConfirmDelete={() => {
|
||||
if (teamID) {
|
||||
deleteTeam({ variables: { teamID } });
|
||||
hidePopup();
|
||||
history.push('/projects');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TeamsRouteProps = {
|
||||
teamID: string;
|
||||
};
|
||||
|
||||
const Teams = () => {
|
||||
const { teamID } = useParams<TeamsRouteProps>();
|
||||
const history = useHistory();
|
||||
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const match = useRouteMatch();
|
||||
useEffect(() => {
|
||||
document.title = 'Citadel | Teams';
|
||||
}, []);
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<span>loading</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (data) {
|
||||
return (
|
||||
<>
|
||||
<GlobalTopNavbar
|
||||
menuType={[
|
||||
{ name: 'Projects', link: `${match.url}` },
|
||||
{ name: 'Members', link: `${match.url}/members` },
|
||||
]}
|
||||
currentTab={currentTab}
|
||||
onSetTab={tab => {
|
||||
setCurrentTab(tab);
|
||||
}}
|
||||
popupContent={<TeamPopup history={history} name={data.findTeam.name} teamID={teamID} />}
|
||||
onSaveProjectName={() => {}}
|
||||
projectID={null}
|
||||
name={data.findTeam.name}
|
||||
/>
|
||||
<OuterWrapper>
|
||||
<Wrapper>
|
||||
<Switch>
|
||||
<Route exact path={match.path}>
|
||||
<Projects teamID={teamID} />
|
||||
</Route>
|
||||
<Route path={`${match.path}/members`}>
|
||||
<Members teamID={teamID} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Wrapper>
|
||||
</OuterWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <div>Error!</div>;
|
||||
};
|
||||
|
||||
export default Teams;
|
81
frontend/src/citadel.d.ts
vendored
Normal file
81
frontend/src/citadel.d.ts
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
interface JWTToken {
|
||||
userId: string;
|
||||
iat: string;
|
||||
exp: string;
|
||||
}
|
||||
|
||||
interface DraggableElement {
|
||||
id: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
type ContextMenuEvent = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
taskID: string;
|
||||
taskGroupID: string;
|
||||
};
|
||||
|
||||
type Role = {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
profileIcon: ProfileIcon;
|
||||
};
|
||||
|
||||
type TaskUser = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
profileIcon: ProfileIcon;
|
||||
username?: string;
|
||||
role?: Role;
|
||||
};
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
type LoginFormData = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type DueDateFormData = {
|
||||
endDate: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
type LoginProps = {
|
||||
onSubmit: (
|
||||
data: LoginFormData,
|
||||
setComplete: (val: boolean) => void,
|
||||
setError: (field: string, eType: string, message: string) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type ElementPosition = {
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
type ElementSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type OnCardMemberClick = ($targetRef: RefObject<HTMLElement>, taskID: string, memberID: string) => void;
|
||||
|
||||
type ElementBounds = {
|
||||
size: ElementSize;
|
||||
position: ElementPosition;
|
||||
};
|
146
frontend/src/index.tsx
Normal file
146
frontend/src/index.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
||||
import { ApolloProvider } from '@apollo/react-hooks';
|
||||
import { ApolloClient } from 'apollo-client';
|
||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
import { HttpLink } from 'apollo-link-http';
|
||||
import { onError } from 'apollo-link-error';
|
||||
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
|
||||
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
|
||||
import App from './App';
|
||||
|
||||
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
||||
|
||||
let forward$;
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: any = [];
|
||||
|
||||
const refreshAuthLogic = (failedRequest: any) =>
|
||||
axios.post('http://localhost:3333/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
|
||||
setAccessToken(tokenRefreshResponse.data.accessToken);
|
||||
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
createAuthRefreshInterceptor(axios, refreshAuthLogic);
|
||||
|
||||
const resolvePendingRequests = () => {
|
||||
pendingRequests.map((callback: any) => callback());
|
||||
pendingRequests = [];
|
||||
};
|
||||
|
||||
const resolvePromise = (resolve: () => void) => {
|
||||
pendingRequests.push(() => resolve());
|
||||
};
|
||||
|
||||
const resetPendingRequests = () => {
|
||||
pendingRequests = [];
|
||||
};
|
||||
|
||||
const setRefreshing = (newVal: boolean) => {
|
||||
isRefreshing = newVal;
|
||||
};
|
||||
|
||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||
if (graphQLErrors) {
|
||||
for (const err of graphQLErrors) {
|
||||
if (err.extensions && err.extensions.code) {
|
||||
switch (err.extensions.code) {
|
||||
case 'UNAUTHENTICATED':
|
||||
if (!isRefreshing) {
|
||||
setRefreshing(true);
|
||||
forward$ = fromPromise(
|
||||
getNewToken()
|
||||
.then((response: any) => {
|
||||
setAccessToken(response.accessToken);
|
||||
resolvePendingRequests();
|
||||
return response.accessToken;
|
||||
})
|
||||
.catch(() => {
|
||||
resetPendingRequests();
|
||||
// TODO
|
||||
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshing(false);
|
||||
}),
|
||||
).filter(value => Boolean(value));
|
||||
} else {
|
||||
forward$ = fromPromise(new Promise(resolvePromise));
|
||||
}
|
||||
return forward$.flatMap(() => forward(operation));
|
||||
default:
|
||||
// pass
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (networkError) {
|
||||
console.log(`[Network error]: ${networkError}`);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const requestLink = new ApolloLink(
|
||||
(operation, forward) =>
|
||||
new Observable((observer: any) => {
|
||||
let handle: any;
|
||||
Promise.resolve(operation)
|
||||
.then((op: any) => {
|
||||
const accessToken = getAccessToken();
|
||||
if (accessToken) {
|
||||
op.setContext({
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
handle = forward(operation).subscribe({
|
||||
next: observer.next.bind(observer),
|
||||
error: observer.error.bind(observer),
|
||||
complete: observer.complete.bind(observer),
|
||||
});
|
||||
})
|
||||
.catch(observer.error.bind(observer));
|
||||
|
||||
return () => {
|
||||
if (handle) {
|
||||
handle.unsubscribe();
|
||||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from([
|
||||
onError(({ graphQLErrors, networkError }) => {
|
||||
if (graphQLErrors) {
|
||||
graphQLErrors.forEach(({ message, locations, path }) =>
|
||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
|
||||
);
|
||||
}
|
||||
if (networkError) {
|
||||
console.log(`[Network error]: ${networkError}`);
|
||||
}
|
||||
}),
|
||||
errorLink,
|
||||
requestLink,
|
||||
new HttpLink({
|
||||
uri: 'http://localhost:3333/graphql',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
]),
|
||||
cache: new InMemoryCache(),
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<ApolloProvider client={client}>
|
||||
<App />
|
||||
</ApolloProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
97
frontend/src/projects.d.ts
vendored
Normal file
97
frontend/src/projects.d.ts
vendored
Normal file
@ -0,0 +1,97 @@
|
||||
type ProfileIcon = {
|
||||
url?: string | null;
|
||||
initials?: string | null;
|
||||
bgColor?: string | null;
|
||||
};
|
||||
|
||||
type TaskGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
tasks: Task[];
|
||||
};
|
||||
|
||||
type LabelColor = {
|
||||
id: string;
|
||||
name: string;
|
||||
colorHex: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type InnerTaskGroup = {
|
||||
id: string;
|
||||
name?: string;
|
||||
position?: number;
|
||||
};
|
||||
|
||||
type TaskLabel = {
|
||||
id: string;
|
||||
assignedDate: string;
|
||||
projectLabel: ProjectLabel;
|
||||
};
|
||||
|
||||
type TaskChecklist = {
|
||||
id: string;
|
||||
position: number;
|
||||
name: string;
|
||||
items: Array<TaskChecklistItem>;
|
||||
};
|
||||
|
||||
type TaskChecklistItem = {
|
||||
id: string;
|
||||
complete: boolean;
|
||||
position: number;
|
||||
name: string;
|
||||
taskChecklistID: string;
|
||||
assigned?: null | TaskUser;
|
||||
dueDate?: null | string;
|
||||
};
|
||||
|
||||
type ChecklistBadge = {
|
||||
complete: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type TaskBadges = {
|
||||
checklist?: ChecklistBadge | null;
|
||||
};
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
taskGroup: InnerTaskGroup;
|
||||
name: string;
|
||||
badges?: TaskBadges;
|
||||
position: number;
|
||||
dueDate?: string;
|
||||
complete?: boolean;
|
||||
labels: TaskLabel[];
|
||||
description?: string | null;
|
||||
assigned?: Array<TaskUser>;
|
||||
checklists?: Array<TaskChecklist> | null;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
projectID: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
teamTitle?: string;
|
||||
taskGroups: TaskGroup[];
|
||||
};
|
||||
|
||||
type Organization = {
|
||||
name: string;
|
||||
teams: Team[];
|
||||
};
|
||||
|
||||
type Team = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProjectLabel = {
|
||||
id: string;
|
||||
createdDate: string;
|
||||
name?: string | null;
|
||||
labelColor: LabelColor;
|
||||
};
|
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
5
frontend/src/setupTests.ts
Normal file
5
frontend/src/setupTests.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
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')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user