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:
parent
8b1de30204
commit
aa84cbabb2
@ -12,7 +12,7 @@ import {
|
||||
} from 'shared/generated/graphql';
|
||||
import styled from 'styled-components';
|
||||
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 produce from 'immer';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
@ -20,6 +20,7 @@ import { useCurrentUser } from 'App/context';
|
||||
import { Redirect } from 'react-router';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import ControlledInput from 'shared/components/ControlledInput';
|
||||
import FormInput from 'shared/components/FormInput';
|
||||
|
||||
const DeleteUserWrapper = styled.div`
|
||||
display: flex;
|
||||
@ -77,7 +78,7 @@ const CreateUserButton = styled(Button)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AddUserInput = styled(ControlledInput)`
|
||||
const AddUserInput = styled(FormInput)`
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
@ -87,7 +88,7 @@ const InputError = styled.span`
|
||||
`;
|
||||
|
||||
type AddUserPopupProps = {
|
||||
onAddUser: (user: CreateUserData) => void;
|
||||
onAddUser: (user: CreateUserData, setError: UseFormSetError<CreateUserData>) => void;
|
||||
};
|
||||
|
||||
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
|
||||
@ -95,16 +96,16 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
control,
|
||||
} = useForm<CreateUserData>();
|
||||
|
||||
const createUser = (data: CreateUserData) => {
|
||||
onAddUser(data);
|
||||
onAddUser(data, setError);
|
||||
};
|
||||
return (
|
||||
<CreateUserForm onSubmit={handleSubmit(createUser)}>
|
||||
<AddUserInput
|
||||
floatingLabel
|
||||
width="100%"
|
||||
label="Full Name"
|
||||
variant="alternate"
|
||||
@ -118,6 +119,7 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
|
||||
variant="alternate"
|
||||
{...register('email', { required: 'Email is required' })}
|
||||
/>
|
||||
{errors.email && <InputError>{errors.email.message}</InputError>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="roleCode"
|
||||
@ -241,10 +243,15 @@ TODO: add permision check
|
||||
$target,
|
||||
<Popup tab={0} title="Add member" onClose={() => hidePopup()}>
|
||||
<AddUserPopup
|
||||
onAddUser={(u) => {
|
||||
onAddUser={(u, setError) => {
|
||||
const { roleCode, ...userData } = u;
|
||||
createUser({ variables: { ...userData, roleCode: roleCode.value } });
|
||||
hidePopup();
|
||||
createUser({
|
||||
variables: { ...userData, roleCode: roleCode.value },
|
||||
})
|
||||
.then(() => hidePopup())
|
||||
.catch((e) => {
|
||||
setError('email', { type: 'validate', message: e.message });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
|
202
frontend/src/shared/components/FormInput/index.tsx
Normal file
202
frontend/src/shared/components/FormInput/index.tsx
Normal 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;
|
@ -59,6 +59,7 @@ type Querier interface {
|
||||
DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error
|
||||
DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) 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)
|
||||
GetAllNotificationsForUserID(ctx context.Context, notifierID uuid.UUID) ([]Notification, error)
|
||||
GetAllOrganizations(ctx context.Context) ([]Organization, error)
|
||||
|
@ -63,6 +63,9 @@ SELECT EXISTS(SELECT 1 FROM user_account WHERE username != 'system');
|
||||
-- name: HasActiveUser :one
|
||||
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
|
||||
INSERT INTO user_account_confirm_token (email) VALUES ($1) RETURNING *;
|
||||
|
||||
|
@ -157,6 +157,22 @@ func (q *Queries) DeleteUserAccountInvitedForEmail(ctx context.Context, email st
|
||||
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
|
||||
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'
|
||||
`
|
||||
|
@ -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) {
|
||||
_, ok := GetUser(ctx)
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return &db.Team{}, nil
|
||||
}
|
||||
// if role == auth.RoleAdmin { // TODO: add permision check
|
||||
if true {
|
||||
createdAt := time.Now().UTC()
|
||||
team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name})
|
||||
return &team, err
|
||||
role, err := r.Repository.GetRoleForUserID(ctx, userID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("while creating team")
|
||||
return &db.Team{}, nil
|
||||
}
|
||||
return &db.Team{}, &gqlerror.Error{
|
||||
Message: "You must be an organization admin to create new teams",
|
||||
Extensions: map[string]interface{}{
|
||||
"code": "1-400",
|
||||
},
|
||||
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()
|
||||
team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name})
|
||||
return &team, err
|
||||
}
|
||||
|
||||
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) {
|
||||
_, ok := GetUser(ctx)
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return &db.UserAccount{}, nil
|
||||
}
|
||||
// if role != auth.RoleAdmin { TODO: add permsion check
|
||||
if true {
|
||||
role, err := r.Repository.GetRoleForUserID(ctx, userID)
|
||||
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{
|
||||
Message: "Must be an organization admin",
|
||||
Extensions: map[string]interface{}{
|
||||
@ -972,6 +980,19 @@ func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserA
|
||||
if err != nil {
|
||||
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{
|
||||
FullName: input.FullName,
|
||||
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) {
|
||||
_, ok := GetUser(ctx)
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return &DeleteUserAccountPayload{Ok: false}, nil
|
||||
}
|
||||
// if role != auth.RoleAdmin { TODO: add permision check
|
||||
if true {
|
||||
return &DeleteUserAccountPayload{Ok: false}, &gqlerror.Error{
|
||||
Message: "User not found",
|
||||
role, err := r.Repository.GetRoleForUserID(ctx, userID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("while deleting user account")
|
||||
return &DeleteUserAccountPayload{}, nil
|
||||
}
|
||||
if ConvertToRoleCode(role.Code) != RoleCodeAdmin {
|
||||
return &DeleteUserAccountPayload{}, &gqlerror.Error{
|
||||
Message: "Must be an organization admin",
|
||||
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
|
||||
}
|
||||
|
||||
// TODO(jordanknott) migrate admin ownership
|
||||
|
||||
err = r.Repository.DeleteUserAccountByID(ctx, input.UserID)
|
||||
if err != nil {
|
||||
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) {
|
||||
_, ok := GetUser(ctx)
|
||||
userID, ok := GetUserID(ctx)
|
||||
if !ok {
|
||||
return &UpdateUserRolePayload{}, nil
|
||||
}
|
||||
// if role != auth.RoleAdmin { TODO: add permision check
|
||||
if true {
|
||||
role, err := r.Repository.GetRoleForUserID(ctx, userID)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("while updating user role")
|
||||
return &UpdateUserRolePayload{}, nil
|
||||
}
|
||||
if ConvertToRoleCode(role.Code) != RoleCodeAdmin {
|
||||
return &UpdateUserRolePayload{}, &gqlerror.Error{
|
||||
Message: "User not found",
|
||||
Message: "Must be an organization admin",
|
||||
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 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.GetAllTeams(ctx)
|
||||
teams, err = r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
|
||||
|
||||
projects := make(map[string]db.Project)
|
||||
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")
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: add permision check
|
||||
if orgRole == "admin" {
|
||||
return r.Repository.GetAllTeams(ctx)
|
||||
}
|
||||
*/
|
||||
|
||||
teams := make(map[string]db.Team)
|
||||
adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
|
||||
if err != nil {
|
||||
|
Loading…
Reference in New Issue
Block a user