From 3e72271d9b4704bcf328b2c1a9fcfec49e92240d Mon Sep 17 00:00:00 2001 From: Jordan Knott Date: Fri, 30 Apr 2021 20:06:05 -0500 Subject: [PATCH] refactor(Project): split out components into their own files --- .../UserManagementPopup/OptionValue.tsx | 16 + .../Project/UserManagementPopup/Styles.ts | 64 +++ .../UserManagementPopup/UserOption.tsx | 39 ++ .../UserManagementPopup/fetchMembers.ts | 82 ++++ .../Project/UserManagementPopup/index.tsx | 82 ++++ frontend/src/Projects/Project/index.tsx | 394 +----------------- .../shared/hooks/useStateWithLocalStorage.ts | 13 + frontend/src/shared/utils/email.ts | 5 + frontend/src/shared/utils/localStorage.ts | 5 + 9 files changed, 326 insertions(+), 374 deletions(-) create mode 100644 frontend/src/Projects/Project/UserManagementPopup/OptionValue.tsx create mode 100644 frontend/src/Projects/Project/UserManagementPopup/Styles.ts create mode 100644 frontend/src/Projects/Project/UserManagementPopup/UserOption.tsx create mode 100644 frontend/src/Projects/Project/UserManagementPopup/fetchMembers.ts create mode 100644 frontend/src/Projects/Project/UserManagementPopup/index.tsx create mode 100644 frontend/src/shared/hooks/useStateWithLocalStorage.ts create mode 100644 frontend/src/shared/utils/email.ts create mode 100644 frontend/src/shared/utils/localStorage.ts diff --git a/frontend/src/Projects/Project/UserManagementPopup/OptionValue.tsx b/frontend/src/Projects/Project/UserManagementPopup/OptionValue.tsx new file mode 100644 index 0000000..0ff7b68 --- /dev/null +++ b/frontend/src/Projects/Project/UserManagementPopup/OptionValue.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Cross } from 'shared/icons'; +import * as S from './Styles'; + +const OptionValue = ({ data, removeProps }: any) => { + return ( + + {data.label} + + + + + ); +}; + +export default OptionValue; diff --git a/frontend/src/Projects/Project/UserManagementPopup/Styles.ts b/frontend/src/Projects/Project/UserManagementPopup/Styles.ts new file mode 100644 index 0000000..04a666b --- /dev/null +++ b/frontend/src/Projects/Project/UserManagementPopup/Styles.ts @@ -0,0 +1,64 @@ +import styled from 'styled-components'; +import Button from 'shared/components/Button'; + +export const OptionWrapper = styled.div<{ isFocused: boolean }>` + cursor: pointer; + padding: 4px 8px; + ${props => props.isFocused && `background: rgba(${props.theme.colors.primary});`} + display: flex; + align-items: center; +`; + +export const OptionContent = styled.div` + display: flex; + flex-direction: column; + margin-left: 12px; +`; + +export const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>` + display: flex; + align-items: center; + font-size: ${p => p.fontSize}px; + color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)}); +`; + +export const OptionValueWrapper = styled.div` + background: rgba(${props => props.theme.colors.bg.primary}); + border-radius: 4px; + margin: 2px; + padding: 3px 6px 3px 4px; + display: flex; + align-items: center; +`; + +export const OptionValueLabel = styled.span` + font-size: 12px; + color: rgba(${props => props.theme.colors.text.secondary}); +`; + +export const OptionValueRemove = styled.button` + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + outline: none; + padding: 0; + margin: 0; + margin-left: 4px; +`; + +export const InviteButton = styled(Button)` + margin-top: 12px; + height: 32px; + padding: 4px 12px; + width: 100%; + justify-content: center; +`; + +export const InviteContainer = styled.div` + min-height: 300px; + display: flex; + flex-direction: column; +`; diff --git a/frontend/src/Projects/Project/UserManagementPopup/UserOption.tsx b/frontend/src/Projects/Project/UserManagementPopup/UserOption.tsx new file mode 100644 index 0000000..3d613c5 --- /dev/null +++ b/frontend/src/Projects/Project/UserManagementPopup/UserOption.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import TaskAssignee from 'shared/components/TaskAssignee'; +import * as S from './Styles'; + +type UserOptionProps = { + innerProps: any; + isDisabled: boolean; + isFocused: boolean; + label: string; + data: any; + getValue: any; +}; + +const UserOption: React.FC = ({ isDisabled, isFocused, innerProps, label, data }) => { + return !isDisabled ? ( + + + + + {label} + + {data.value.type === 2 && ( + + Joined + + )} + + + ) : null; +}; + +export default UserOption; diff --git a/frontend/src/Projects/Project/UserManagementPopup/fetchMembers.ts b/frontend/src/Projects/Project/UserManagementPopup/fetchMembers.ts new file mode 100644 index 0000000..85e3317 --- /dev/null +++ b/frontend/src/Projects/Project/UserManagementPopup/fetchMembers.ts @@ -0,0 +1,82 @@ +import gql from 'graphql-tag'; +import isValidEmail from 'shared/utils/email'; + +type MemberFilterOptions = { + projectID?: null | string; + teamID?: null | string; + organization?: boolean; +}; + +export default async function(client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) { + if (input && input.trim().length < 3) { + return []; + } + const res = await client.query({ + query: gql` + query { + searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) { + id + similarity + status + user { + id + fullName + email + profileIcon { + url + initials + bgColor + } + } + } + } + `, + }); + + let results: any = []; + const emails: Array = []; + if (res.data && res.data.searchMembers) { + results = [ + ...res.data.searchMembers.map((m: any) => { + if (m.status === 'INVITED') { + return { + label: m.id, + value: { + id: m.id, + type: 2, + profileIcon: { + bgColor: '#ccc', + initials: m.id.charAt(0), + }, + }, + }; + } + + emails.push(m.user.email); + return { + label: m.user.fullName, + value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon }, + }; + }), + ]; + } + + if (isValidEmail(input) && !emails.find(e => e === input)) { + results = [ + ...results, + { + label: input, + value: { + id: input, + type: 1, + profileIcon: { + bgColor: '#ccc', + initials: input.charAt(0), + }, + }, + }, + ]; + } + + return results; +} diff --git a/frontend/src/Projects/Project/UserManagementPopup/index.tsx b/frontend/src/Projects/Project/UserManagementPopup/index.tsx new file mode 100644 index 0000000..7c2c1a3 --- /dev/null +++ b/frontend/src/Projects/Project/UserManagementPopup/index.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import AsyncSelect from 'react-select/async'; +import { useApolloClient } from '@apollo/react-hooks'; +import { colourStyles } from 'shared/components/Select'; +import { Popup } from 'shared/components/PopupMenu'; +import OptionValue from './OptionValue'; +import UserOption from './UserOption'; +import fetchMembers from './fetchMembers'; +import * as S from './Styles'; + +type InviteUserData = { + email?: string; + userID?: string; +}; + +type UserManagementPopupProps = { + projectID: string; + users: Array; + projectMembers: Array; + onInviteProjectMembers: (data: Array) => void; +}; + +const UserManagementPopup: React.FC = ({ + projectID, + users, + projectMembers, + onInviteProjectMembers, +}) => { + const client = useApolloClient(); + const [invitedUsers, setInvitedUsers] = useState | null>(null); + return ( + + + option.value.id} + placeholder="Email address or username" + noOptionsMessage={() => null} + onChange={(e: any) => { + setInvitedUsers(e); + }} + isMulti + autoFocus + cacheOptions + styles={colourStyles} + defaultOption + components={{ + MultiValue: OptionValue, + Option: UserOption, + IndicatorSeparator: null, + DropdownIndicator: null, + }} + loadOptions={(i, cb) => fetchMembers(client, projectID, {}, i, cb)} + /> + + { + if (invitedUsers) { + onInviteProjectMembers( + invitedUsers.map(user => { + if (user.value.type === 0) { + return { + userID: user.value.id, + }; + } + return { + email: user.value.id, + }; + }), + ); + } + }} + disabled={invitedUsers === null} + hoverVariant="none" + fontSize="16px" + > + Send Invite + + + ); +}; + +export default UserManagementPopup; diff --git a/frontend/src/Projects/Project/index.tsx b/frontend/src/Projects/Project/index.tsx index 4664dc0..9cc6bf1 100644 --- a/frontend/src/Projects/Project/index.tsx +++ b/frontend/src/Projects/Project/index.tsx @@ -1,11 +1,9 @@ // LOC830 -import React, { useState, useRef, useEffect, useContext } from 'react'; +import React, { useRef, useEffect } from 'react'; import updateApolloCache from 'shared/utils/cache'; import GlobalTopNavbar from 'App/TopNavbar'; import ProjectPopup from 'App/TopNavbar/ProjectPopup'; -import styled from 'styled-components/macro'; -import AsyncSelect from 'react-select/async'; -import { usePopup, Popup } from 'shared/components/PopupMenu'; +import { usePopup } from 'shared/components/PopupMenu'; import { useParams, Route, @@ -24,392 +22,51 @@ import { useFindProjectQuery, useDeleteInvitedProjectMemberMutation, useUpdateTaskNameMutation, - useCreateTaskMutation, useDeleteTaskMutation, - useUpdateTaskLocationMutation, - useUpdateTaskGroupLocationMutation, - useCreateTaskGroupMutation, useUpdateTaskDescriptionMutation, FindProjectDocument, FindProjectQuery, } from 'shared/generated/graphql'; import produce from 'immer'; -import UserContext, { useCurrentUser } from 'App/context'; -import Input from 'shared/components/Input'; -import Member from 'shared/components/Member'; -import EmptyBoard from 'shared/components/EmptyBoard'; import NOOP from 'shared/utils/noop'; -import { Lock, Cross } from 'shared/icons'; -import Button from 'shared/components/Button'; -import { useApolloClient } from '@apollo/react-hooks'; -import TaskAssignee from 'shared/components/TaskAssignee'; -import gql from 'graphql-tag'; -import { colourStyles } from 'shared/components/Select'; +import useStateWithLocalStorage from 'shared/hooks/useStateWithLocalStorage'; +import localStorage from 'shared/utils/localStorage'; import Board, { BoardLoading } from './Board'; import Details from './Details'; import LabelManagerEditor from './LabelManagerEditor'; -import { mixin } from '../../shared/utils/styles'; - -const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant'; - -const RFC2822_EMAIL = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/; - -const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch>] => { - const [value, setValue] = React.useState(localStorage.getItem(localStorageKey) || ''); - - React.useEffect(() => { - localStorage.setItem(localStorageKey, value); - }, [value]); - - return [value, setValue]; -}; - -const SearchInput = styled(Input)` - margin: 0; -`; - -const UserMember = styled(Member)` - padding: 4px 0; - cursor: pointer; - &:hover { - background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)}; - } - border-radius: 6px; -`; - -const MemberList = styled.div` - margin: 8px 0; -`; - -type InviteUserData = { - email?: string; - suerID?: string; -}; -type UserManagementPopupProps = { - projectID: string; - users: Array; - projectMembers: Array; - onInviteProjectMembers: (data: Array) => void; -}; - -const VisibiltyPrivateIcon = styled(Lock)` - padding-right: 4px; -`; - -const VisibiltyButtonText = styled.span` - color: rgba(${props => props.theme.colors.text.primary}); -`; - -const ShareActions = styled.div` - border-top: 1px solid #414561; - margin-top: 8px; - padding-top: 8px; - display: flex; - align-items: center; - justify-content: space-between; -`; - -const VisibiltyButton = styled.button` - cursor: pointer; - margin: 2px 4px; - padding: 2px 4px; - align-items: center; - justify-content: center; - border-bottom: 1px solid transparent; - &:hover ${VisibiltyButtonText} { - color: rgba(${props => props.theme.colors.text.secondary}); - } - &:hover ${VisibiltyPrivateIcon} { - fill: rgba(${props => props.theme.colors.text.secondary}); - stroke: rgba(${props => props.theme.colors.text.secondary}); - } - &:hover { - border-bottom: 1px solid rgba(${props => props.theme.colors.primary}); - } -`; - -type MemberFilterOptions = { - projectID?: null | string; - teamID?: null | string; - organization?: boolean; -}; - -const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => { - if (input && input.trim().length < 3) { - return []; - } - const res = await client.query({ - query: gql` - query { - searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) { - id - similarity - status - user { - id - fullName - email - profileIcon { - url - initials - bgColor - } - } - } - } - `, - }); - - let results: any = []; - const emails: Array = []; - if (res.data && res.data.searchMembers) { - results = [ - ...res.data.searchMembers.map((m: any) => { - if (m.status === 'INVITED') { - return { - label: m.id, - value: { - id: m.id, - type: 2, - profileIcon: { - bgColor: '#ccc', - initials: m.id.charAt(0), - }, - }, - }; - } - - emails.push(m.user.email); - return { - label: m.user.fullName, - value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon }, - }; - }), - ]; - } - - if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) { - results = [ - ...results, - { - label: input, - value: { - id: input, - type: 1, - profileIcon: { - bgColor: '#ccc', - initials: input.charAt(0), - }, - }, - }, - ]; - } - - return results; -}; - -type UserOptionProps = { - innerProps: any; - isDisabled: boolean; - isFocused: boolean; - label: string; - data: any; - getValue: any; -}; - -const OptionWrapper = styled.div<{ isFocused: boolean }>` - cursor: pointer; - padding: 4px 8px; - ${props => props.isFocused && `background: rgba(${props.theme.colors.primary});`} - display: flex; - align-items: center; -`; -const OptionContent = styled.div` - display: flex; - flex-direction: column; - margin-left: 12px; -`; - -const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>` - display: flex; - align-items: center; - font-size: ${p => p.fontSize}px; - color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)}); -`; - -const UserOption: React.FC = ({ isDisabled, isFocused, innerProps, label, data }) => { - return !isDisabled ? ( - - - - - {label} - - {data.value.type === 2 && ( - - Joined - - )} - - - ) : null; -}; - -const OptionValueWrapper = styled.div` - background: rgba(${props => props.theme.colors.bg.primary}); - border-radius: 4px; - margin: 2px; - padding: 3px 6px 3px 4px; - display: flex; - align-items: center; -`; - -const OptionValueLabel = styled.span` - font-size: 12px; - color: rgba(${props => props.theme.colors.text.secondary}); -`; - -const OptionValueRemove = styled.button` - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - outline: none; - padding: 0; - margin: 0; - margin-left: 4px; -`; -const OptionValue = ({ data, removeProps }: any) => { - return ( - - {data.label} - - - - - ); -}; - -const InviteButton = styled(Button)` - margin-top: 12px; - height: 32px; - padding: 4px 12px; - width: 100%; - justify-content: center; -`; - -const InviteContainer = styled.div` - min-height: 300px; - display: flex; - flex-direction: column; -`; - -const UserManagementPopup: React.FC = ({ - projectID, - users, - projectMembers, - onInviteProjectMembers, -}) => { - const client = useApolloClient(); - const [invitedUsers, setInvitedUsers] = useState | null>(null); - return ( - - - option.value.id} - placeholder="Email address or username" - noOptionsMessage={() => null} - onChange={(e: any) => { - setInvitedUsers(e); - }} - isMulti - autoFocus - cacheOptions - styles={colourStyles} - defaultOption - components={{ - MultiValue: OptionValue, - Option: UserOption, - IndicatorSeparator: null, - DropdownIndicator: null, - }} - loadOptions={(i, cb) => fetchMembers(client, projectID, {}, i, cb)} - /> - - { - if (invitedUsers) { - onInviteProjectMembers( - invitedUsers.map(user => { - if (user.value.type === 0) { - return { - userID: user.value.id, - }; - } - return { - email: user.value.id, - }; - }), - ); - } - }} - disabled={invitedUsers === null} - hoverVariant="none" - fontSize="16px" - > - Send Invite - - - ); -}; +import UserManagementPopup from './UserManagementPopup'; type TaskRouteProps = { taskID: string; }; -interface QuickCardEditorState { - isOpen: boolean; - target: React.RefObject | null; - taskID: string | null; - taskGroupID: string | null; -} - interface ProjectParams { projectID: string; } -const initialQuickCardEditorState: QuickCardEditorState = { - taskID: null, - taskGroupID: null, - isOpen: false, - target: null, -}; - const Project = () => { const { projectID } = useParams(); const history = useHistory(); const match = useRouteMatch(); + const location = useLocation(); + + const { showPopup, hidePopup } = usePopup(); + const labelsRef = useRef>([]); + const [value, setValue] = useStateWithLocalStorage(localStorage.CARD_LABEL_VARIANT_STORAGE_KEY); + const taskLabelsRef = useRef>([]); const [updateTaskDescription] = useUpdateTaskDescriptionMutation(); - const taskLabelsRef = useRef>([]); + const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation(); + const [updateTaskName] = useUpdateTaskNameMutation(); + const { data, error } = useFindProjectQuery({ + variables: { projectID }, + pollInterval: 3000, + }); const [toggleTaskLabel] = useToggleTaskLabelMutation({ onCompleted: newTaskLabel => { taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels; }, }); - - const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY); - const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation(); - const [deleteTask] = useDeleteTaskMutation({ update: (client, resp) => updateApolloCache( @@ -433,13 +90,6 @@ const Project = () => { ), }); - const [updateTaskName] = useUpdateTaskNameMutation(); - - const { loading, data, error } = useFindProjectQuery({ - variables: { projectID }, - pollInterval: 3000, - }); - const [updateProjectName] = useUpdateProjectNameMutation({ update: (client, newName) => { updateApolloCache( @@ -507,20 +157,16 @@ const Project = () => { }, }); - const { user } = useCurrentUser(); - const location = useLocation(); - - const { showPopup, hidePopup } = usePopup(); - const $labelsRef = useRef(null); - const labelsRef = useRef>([]); useEffect(() => { if (data) { document.title = `${data.findProject.name} | Taskcafé`; } }, [data]); + if (error) { history.push('/projects'); } + if (data) { labelsRef.current = data.findProject.labels; @@ -530,7 +176,7 @@ const Project = () => { onChangeRole={(userID, roleCode) => { updateProjectMemberRole({ variables: { userID, roleCode, projectID } }); }} - onChangeProjectOwner={uid => { + onChangeProjectOwner={() => { hidePopup(); }} onRemoveFromBoard={userID => { diff --git a/frontend/src/shared/hooks/useStateWithLocalStorage.ts b/frontend/src/shared/hooks/useStateWithLocalStorage.ts new file mode 100644 index 0000000..07ac130 --- /dev/null +++ b/frontend/src/shared/hooks/useStateWithLocalStorage.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch>] => { + const [value, setValue] = React.useState(localStorage.getItem(localStorageKey) || ''); + + React.useEffect(() => { + localStorage.setItem(localStorageKey, value); + }, [value]); + + return [value, setValue]; +}; + +export default useStateWithLocalStorage; diff --git a/frontend/src/shared/utils/email.ts b/frontend/src/shared/utils/email.ts new file mode 100644 index 0000000..5addb3d --- /dev/null +++ b/frontend/src/shared/utils/email.ts @@ -0,0 +1,5 @@ +const RFC2822_EMAIL = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/; + +export default function isValidEmail(target: string) { + return RFC2822_EMAIL.test(target); +} diff --git a/frontend/src/shared/utils/localStorage.ts b/frontend/src/shared/utils/localStorage.ts new file mode 100644 index 0000000..3d893e1 --- /dev/null +++ b/frontend/src/shared/utils/localStorage.ts @@ -0,0 +1,5 @@ +const localStorage = { + CARD_LABEL_VARIANT_STORAGE_KEY: 'card_label_variant', +}; + +export default localStorage;