fix: add user popup is submittable again

react-form-hooks no longer played nice with custom input. created
a third input type `FormInput` that is made to play well
with the react-form-hooks.

also fixes auto complete overriding bg + text color on inputs.
This commit is contained in:
Jordan Knott 2021-10-06 19:03:38 -05:00
parent 8b1de30204
commit aa84cbabb2
6 changed files with 292 additions and 50 deletions

View File

@ -12,7 +12,7 @@ import {
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import styled from 'styled-components'; import styled from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller, UseFormSetError } from 'react-hook-form';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
@ -20,6 +20,7 @@ import { useCurrentUser } from 'App/context';
import { Redirect } from 'react-router'; import { Redirect } from 'react-router';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import ControlledInput from 'shared/components/ControlledInput'; import ControlledInput from 'shared/components/ControlledInput';
import FormInput from 'shared/components/FormInput';
const DeleteUserWrapper = styled.div` const DeleteUserWrapper = styled.div`
display: flex; display: flex;
@ -77,7 +78,7 @@ const CreateUserButton = styled(Button)`
width: 100%; width: 100%;
`; `;
const AddUserInput = styled(ControlledInput)` const AddUserInput = styled(FormInput)`
margin-bottom: 8px; margin-bottom: 8px;
`; `;
@ -87,7 +88,7 @@ const InputError = styled.span`
`; `;
type AddUserPopupProps = { type AddUserPopupProps = {
onAddUser: (user: CreateUserData) => void; onAddUser: (user: CreateUserData, setError: UseFormSetError<CreateUserData>) => void;
}; };
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => { const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
@ -95,16 +96,16 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
setError,
control, control,
} = useForm<CreateUserData>(); } = useForm<CreateUserData>();
const createUser = (data: CreateUserData) => { const createUser = (data: CreateUserData) => {
onAddUser(data); onAddUser(data, setError);
}; };
return ( return (
<CreateUserForm onSubmit={handleSubmit(createUser)}> <CreateUserForm onSubmit={handleSubmit(createUser)}>
<AddUserInput <AddUserInput
floatingLabel
width="100%" width="100%"
label="Full Name" label="Full Name"
variant="alternate" variant="alternate"
@ -118,6 +119,7 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
variant="alternate" variant="alternate"
{...register('email', { required: 'Email is required' })} {...register('email', { required: 'Email is required' })}
/> />
{errors.email && <InputError>{errors.email.message}</InputError>}
<Controller <Controller
control={control} control={control}
name="roleCode" name="roleCode"
@ -241,10 +243,15 @@ TODO: add permision check
$target, $target,
<Popup tab={0} title="Add member" onClose={() => hidePopup()}> <Popup tab={0} title="Add member" onClose={() => hidePopup()}>
<AddUserPopup <AddUserPopup
onAddUser={(u) => { onAddUser={(u, setError) => {
const { roleCode, ...userData } = u; const { roleCode, ...userData } = u;
createUser({ variables: { ...userData, roleCode: roleCode.value } }); createUser({
hidePopup(); variables: { ...userData, roleCode: roleCode.value },
})
.then(() => hidePopup())
.catch((e) => {
setError('email', { type: 'validate', message: e.message });
});
}} }}
/> />
</Popup>, </Popup>,

View File

@ -0,0 +1,202 @@
import React, { useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components/macro';
import theme from '../../../App/ThemeStyles';
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;')}
&:-webkit-autofill, &:-webkit-autofill:hover, &:-webkit-autofill:focus, &:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px #262c49 inset !important;
}
&:-webkit-autofill {
-webkit-text-fill-color: #c2c6dc !important;
}
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 ${(props) => props.theme.colors.primary};
background: ${(props) => props.focusBg};
}
&:focus ~ ${InputLabel} {
color: ${(props) => props.theme.colors.primary};
transform: translate(-3px, -90%);
}
${(props) =>
props.hasValue &&
css`
& ~ ${InputLabel} {
color: ${props.theme.colors.primary};
transform: translate(-3px, -90%);
}
`}
`;
const Icon = styled.div`
display: flex;
left: 16px;
position: absolute;
`;
type FormInputProps = {
variant?: 'normal' | 'alternate';
disabled?: boolean;
label?: string;
width?: string;
floatingLabel?: boolean;
placeholder?: string;
icon?: JSX.Element;
type?: string;
autocomplete?: boolean;
autoFocus?: boolean;
autoSelect?: boolean;
id?: string;
name?: string;
onChange: any;
onBlur: any;
className?: string;
defaultValue?: string;
value?: string;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
};
function useCombinedRefs(...refs: any) {
const targetRef = React.useRef();
React.useEffect(() => {
refs.forEach((ref: any) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(targetRef.current);
} else {
ref.current = targetRef.current;
}
});
}, [refs]);
return targetRef;
}
const FormInput = React.forwardRef(
(
{
disabled = false,
width = 'auto',
variant = 'normal',
type = 'text',
autoFocus = false,
autoSelect = false,
autocomplete,
label,
placeholder,
onBlur,
onChange,
icon,
name,
className,
onClick,
floatingLabel,
defaultValue,
value,
id,
}: FormInputProps,
$ref: any,
) => {
const [hasValue, setHasValue] = useState(defaultValue !== '');
const borderColor = variant === 'normal' ? 'rgba(0,0,0,0.2)' : theme.colors.alternate;
const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary;
// Merge forwarded ref and internal ref in order to be able to access the ref in the useEffect
// The forwarded ref is not accessible by itself, which is what the innerRef & combined ref is for
// TODO(jordanknott): This is super ugly, find a better approach?
const $innerRef = React.useRef<HTMLInputElement>(null);
const combinedRef: any = useCombinedRefs($ref, $innerRef);
useEffect(() => {
if (combinedRef && combinedRef.current) {
if (autoFocus) {
combinedRef.current.focus();
}
if (autoSelect) {
combinedRef.current.select();
}
}
}, []);
return (
<InputWrapper className={className} width={width}>
<InputInput
onChange={(e) => {
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
onChange(e);
}}
disabled={disabled}
hasValue={hasValue}
ref={combinedRef}
id={id}
type={type}
name={name}
onClick={onClick}
autoComplete={autocomplete ? 'on' : 'off'}
defaultValue={defaultValue}
onBlur={onBlur}
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 FormInput;

View File

@ -59,6 +59,7 @@ type Querier interface {
DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error
DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) error DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) error
DeleteUserAccountInvitedForEmail(ctx context.Context, email string) error DeleteUserAccountInvitedForEmail(ctx context.Context, email string) error
DoesUserExist(ctx context.Context, arg DoesUserExistParams) (bool, error)
GetActivityForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskActivity, error) GetActivityForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskActivity, error)
GetAllNotificationsForUserID(ctx context.Context, notifierID uuid.UUID) ([]Notification, error) GetAllNotificationsForUserID(ctx context.Context, notifierID uuid.UUID) ([]Notification, error)
GetAllOrganizations(ctx context.Context) ([]Organization, error) GetAllOrganizations(ctx context.Context) ([]Organization, error)

View File

@ -63,6 +63,9 @@ SELECT EXISTS(SELECT 1 FROM user_account WHERE username != 'system');
-- name: HasActiveUser :one -- name: HasActiveUser :one
SELECT EXISTS(SELECT 1 FROM user_account WHERE username != 'system' AND active = true); SELECT EXISTS(SELECT 1 FROM user_account WHERE username != 'system' AND active = true);
-- name: DoesUserExist :one
SELECT EXISTS(SELECT 1 FROM user_account WHERE email = $1 OR username = $2);
-- name: CreateConfirmToken :one -- name: CreateConfirmToken :one
INSERT INTO user_account_confirm_token (email) VALUES ($1) RETURNING *; INSERT INTO user_account_confirm_token (email) VALUES ($1) RETURNING *;

View File

@ -157,6 +157,22 @@ func (q *Queries) DeleteUserAccountInvitedForEmail(ctx context.Context, email st
return err return err
} }
const doesUserExist = `-- name: DoesUserExist :one
SELECT EXISTS(SELECT 1 FROM user_account WHERE email = $1 OR username = $2)
`
type DoesUserExistParams struct {
Email string `json:"email"`
Username string `json:"username"`
}
func (q *Queries) DoesUserExist(ctx context.Context, arg DoesUserExistParams) (bool, error) {
row := q.db.QueryRowContext(ctx, doesUserExist, arg.Email, arg.Username)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const getAllUserAccounts = `-- name: GetAllUserAccounts :many const getAllUserAccounts = `-- name: GetAllUserAccounts :many
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM user_account WHERE username != 'system' SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM user_account WHERE username != 'system'
` `

View File

@ -872,22 +872,26 @@ func (r *mutationResolver) DeleteTeam(ctx context.Context, input DeleteTeam) (*D
} }
func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*db.Team, error) { func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*db.Team, error) {
_, ok := GetUser(ctx) userID, ok := GetUserID(ctx)
if !ok { if !ok {
return &db.Team{}, nil return &db.Team{}, nil
} }
// if role == auth.RoleAdmin { // TODO: add permision check role, err := r.Repository.GetRoleForUserID(ctx, userID)
if true { if err != nil {
log.WithError(err).Error("while creating team")
return &db.Team{}, nil
}
if ConvertToRoleCode(role.Code) != RoleCodeAdmin {
return &db.Team{}, &gqlerror.Error{
Message: "Must be an organization admin",
Extensions: map[string]interface{}{
"code": "0-400",
},
}
}
createdAt := time.Now().UTC() createdAt := time.Now().UTC()
team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name}) team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name})
return &team, err return &team, err
}
return &db.Team{}, &gqlerror.Error{
Message: "You must be an organization admin to create new teams",
Extensions: map[string]interface{}{
"code": "1-400",
},
}
} }
func (r *mutationResolver) CreateTeamMember(ctx context.Context, input CreateTeamMember) (*CreateTeamMemberPayload, error) { func (r *mutationResolver) CreateTeamMember(ctx context.Context, input CreateTeamMember) (*CreateTeamMemberPayload, error) {
@ -954,12 +958,16 @@ func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTea
} }
func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*db.UserAccount, error) { func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*db.UserAccount, error) {
_, ok := GetUser(ctx) userID, ok := GetUserID(ctx)
if !ok { if !ok {
return &db.UserAccount{}, nil return &db.UserAccount{}, nil
} }
// if role != auth.RoleAdmin { TODO: add permsion check role, err := r.Repository.GetRoleForUserID(ctx, userID)
if true { if err != nil {
log.WithError(err).Error("while creating user account")
return &db.UserAccount{}, nil
}
if ConvertToRoleCode(role.Code) != RoleCodeAdmin {
return &db.UserAccount{}, &gqlerror.Error{ return &db.UserAccount{}, &gqlerror.Error{
Message: "Must be an organization admin", Message: "Must be an organization admin",
Extensions: map[string]interface{}{ Extensions: map[string]interface{}{
@ -972,6 +980,19 @@ func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserA
if err != nil { if err != nil {
return &db.UserAccount{}, err return &db.UserAccount{}, err
} }
userExists, err := r.Repository.DoesUserExist(ctx, db.DoesUserExistParams{Username: input.Username, Email: input.Email})
if err != nil {
return &db.UserAccount{}, err
}
if userExists {
return &db.UserAccount{}, &gqlerror.Error{
Message: "User with that username or email already exists",
Extensions: map[string]interface{}{
"code": "0-300",
},
}
}
userAccount, err := r.Repository.CreateUserAccount(ctx, db.CreateUserAccountParams{ userAccount, err := r.Repository.CreateUserAccount(ctx, db.CreateUserAccountParams{
FullName: input.FullName, FullName: input.FullName,
RoleCode: input.RoleCode, RoleCode: input.RoleCode,
@ -986,16 +1007,20 @@ func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserA
} }
func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUserAccount) (*DeleteUserAccountPayload, error) { func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUserAccount) (*DeleteUserAccountPayload, error) {
_, ok := GetUser(ctx) userID, ok := GetUserID(ctx)
if !ok { if !ok {
return &DeleteUserAccountPayload{Ok: false}, nil return &DeleteUserAccountPayload{Ok: false}, nil
} }
// if role != auth.RoleAdmin { TODO: add permision check role, err := r.Repository.GetRoleForUserID(ctx, userID)
if true { if err != nil {
return &DeleteUserAccountPayload{Ok: false}, &gqlerror.Error{ log.WithError(err).Error("while deleting user account")
Message: "User not found", return &DeleteUserAccountPayload{}, nil
}
if ConvertToRoleCode(role.Code) != RoleCodeAdmin {
return &DeleteUserAccountPayload{}, &gqlerror.Error{
Message: "Must be an organization admin",
Extensions: map[string]interface{}{ Extensions: map[string]interface{}{
"code": "0-401", "code": "0-400",
}, },
} }
} }
@ -1004,8 +1029,6 @@ func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUs
return &DeleteUserAccountPayload{Ok: false}, err return &DeleteUserAccountPayload{Ok: false}, err
} }
// TODO(jordanknott) migrate admin ownership
err = r.Repository.DeleteUserAccountByID(ctx, input.UserID) err = r.Repository.DeleteUserAccountByID(ctx, input.UserID)
if err != nil { if err != nil {
return &DeleteUserAccountPayload{Ok: false}, err return &DeleteUserAccountPayload{Ok: false}, err
@ -1062,16 +1085,20 @@ func (r *mutationResolver) UpdateUserPassword(ctx context.Context, input UpdateU
} }
func (r *mutationResolver) UpdateUserRole(ctx context.Context, input UpdateUserRole) (*UpdateUserRolePayload, error) { func (r *mutationResolver) UpdateUserRole(ctx context.Context, input UpdateUserRole) (*UpdateUserRolePayload, error) {
_, ok := GetUser(ctx) userID, ok := GetUserID(ctx)
if !ok { if !ok {
return &UpdateUserRolePayload{}, nil return &UpdateUserRolePayload{}, nil
} }
// if role != auth.RoleAdmin { TODO: add permision check role, err := r.Repository.GetRoleForUserID(ctx, userID)
if true { if err != nil {
log.WithError(err).Error("while updating user role")
return &UpdateUserRolePayload{}, nil
}
if ConvertToRoleCode(role.Code) != RoleCodeAdmin {
return &UpdateUserRolePayload{}, &gqlerror.Error{ return &UpdateUserRolePayload{}, &gqlerror.Error{
Message: "User not found", Message: "Must be an organization admin",
Extensions: map[string]interface{}{ Extensions: map[string]interface{}{
"code": "0-401", "code": "0-400",
}, },
} }
} }
@ -1331,14 +1358,7 @@ func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]
var teams []db.Team var teams []db.Team
var err error var err error
/* TODO: add permsion check
if orgRole == "admin" {
teams, err = r.Repository.GetAllTeams(ctx)
} else {
teams, err = r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID) teams, err = r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
}
*/
teams, err = r.Repository.GetAllTeams(ctx)
projects := make(map[string]db.Project) projects := make(map[string]db.Project)
for _, team := range teams { for _, team := range teams {
@ -1390,13 +1410,6 @@ func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
return []db.Team{}, errors.New("internal error") return []db.Team{}, errors.New("internal error")
} }
/*
TODO: add permision check
if orgRole == "admin" {
return r.Repository.GetAllTeams(ctx)
}
*/
teams := make(map[string]db.Team) teams := make(map[string]db.Team)
adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID) adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
if err != nil { if err != nil {