Fix up KeyValue component, fix save issue of Bio, simplify UserProfile

This commit is contained in:
Frank Johnson
2021-09-15 01:53:44 -04:00
parent fc6cded59e
commit 7b2be01430
6 changed files with 245 additions and 240 deletions

View File

@@ -1,27 +1,47 @@
import Svg from 'src/components/Svg/Svg' import Svg from 'src/components/Svg/Svg'
interface EditToggleType {
hasPermissionToEdit: boolean
onEdit?: React.MouseEventHandler
isEditing?: boolean
}
const EditToggle = ({
onEdit = () => { console.error('Field declared editable without edit action.') },
isEditing = false,
} : EditToggleType) => (
(isEditing ? (
<button
className="font-fira-sans text-ch-gray-300 items-center ml-4 grid grid-flow-col-dense p-px px-2 gap-2 bg-ch-blue-500 bg-opacity-50 hover:bg-opacity-70 rounded-sm"
id="rename-button"
onClick={onEdit}
>
<Svg name="check" className="w-6 h-6" strokeWidth={3} />
<span>Update</span>
</button>
) : (
<button onClick={onEdit}>
<Svg name="pencil-solid" className="h-4 w-4 ml-4 mb-2" />
</button>
))
)
interface KeyValueType { interface KeyValueType {
keyName: string keyName: string
children: React.ReactNode children: React.ReactNode
hide?: boolean
canEdit?: boolean
onEdit?: () => void
isEditable?: boolean
bottom?: boolean bottom?: boolean
className?: string className?: string
edit?: EditToggleType
} }
const KeyValue = ({ const KeyValue = ({
keyName, keyName,
children, children,
hide = false,
canEdit = false,
onEdit,
isEditable = false,
bottom = false, bottom = false,
className = '', className = '',
edit = { hasPermissionToEdit: false },
}: KeyValueType) => { }: KeyValueType) => {
if (!children || hide) return null if (!children) return null
return ( return (
<div className={'flex flex-col ' + className}> <div className={'flex flex-col ' + className}>
<div <div
@@ -30,22 +50,8 @@ const KeyValue = ({
(bottom ? 'order-2' : '') (bottom ? 'order-2' : '')
} }
> >
<span className={isEditable ? 'text-ch-blue-300' : ''}>{keyName}</span> <span className={edit ? 'text-ch-blue-300' : ''}>{keyName}</span>
{canEdit && {edit && edit.hasPermissionToEdit && <EditToggle {...edit} /> }
(isEditable ? (
<button
className="font-fira-sans text-ch-gray-300 items-center ml-4 grid grid-flow-col-dense p-px px-2 gap-2 bg-ch-blue-500 bg-opacity-50 hover:bg-opacity-70 rounded-sm"
id="rename-button"
onClick={onEdit}
>
<Svg name="check" className="w-6 h-6" strokeWidth={3} />
<span>Update</span>
</button>
) : (
<button onClick={onEdit}>
<Svg name="pencil-solid" className="h-4 w-4 ml-4 mb-2" />
</button>
))}
</div> </div>
<div className={'text-ch-gray-300 ' + (bottom ? 'mb-1' : 'mt-1')}> <div className={'text-ch-gray-300 ' + (bottom ? 'mb-1' : 'mt-1')}>
{children} {children}

View File

@@ -33,8 +33,8 @@ const ProjectCard = ({ title, mainImage, user, Reaction, cadPackage }) => (
<div className="flex items-center mt-1"> <div className="flex items-center mt-1">
<div className="w-8 h-8 overflow-hidden rounded-full border border-ch-gray-300 shadow"> <div className="w-8 h-8 overflow-hidden rounded-full border border-ch-gray-300 shadow">
<ImageFallback <ImageFallback
imageId={user?.image} // http://res.cloudinary.com/irevdev/image/upload/c_scale,w_50/v1/CadHub/bc7smqwo9qqmrloyf9xr imageId={user?.image}
width={80} // http://res.cloudinary.com/irevdev/image/upload/c_scale,w_300/v1/CadHub/bc7smqwo9qqmrloyf9xr width={80}
/> />
</div> </div>
<div className="ml-3 text-lg text-ch-gray-300 font-fira-sans"> <div className="ml-3 text-lg text-ch-gray-300 font-fira-sans">

View File

@@ -27,7 +27,7 @@ const ProjectProfile = ({
onComment, onComment,
}) => { }) => {
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
const [isEditable, setIsEditable] = useState(false) const [isEditing, setIsEditing] = useState(false)
const onCommentClear = () => { const onCommentClear = () => {
onComment(comment) onComment(comment)
setComment('') setComment('')
@@ -36,14 +36,14 @@ const ProjectProfile = ({
const [isReactionsModalOpen, setIsReactionsModalOpen] = useState(false) const [isReactionsModalOpen, setIsReactionsModalOpen] = useState(false)
const { currentUser } = useAuth() const { currentUser } = useAuth()
const editorRef = useRef(null) const editorRef = useRef(null)
const canEdit = const hasPermissionToEdit =
currentUser?.sub === userProject.id || currentUser?.roles.includes('admin') currentUser?.sub === userProject.id || currentUser?.roles.includes('admin')
const project = userProject?.Project const project = userProject?.Project
const emotes = countEmotes(project?.Reaction) const emotes = countEmotes(project?.Reaction)
const userEmotes = project?.userReactions.map(({ emote }) => emote) const userEmotes = project?.userReactions.map(({ emote }) => emote)
useEffect(() => { useEffect(() => {
isEditable && isEditing &&
!canEdit && !hasPermissionToEdit &&
navigate( navigate(
routes.project({ routes.project({
userName: userProject.userName, userName: userProject.userName,
@@ -55,7 +55,7 @@ const ProjectProfile = ({
const [newDescription, setNewDescription] = useState(project?.description) const [newDescription, setNewDescription] = useState(project?.description)
const onDescriptionChange = (description) => setNewDescription(description()) const onDescriptionChange = (description) => setNewDescription(description())
const onEditSaveClick = () => { const onEditSaveClick = () => {
if (isEditable) { if (isEditing) {
onSave(project?.id, { description: newDescription }) onSave(project?.id, { description: newDescription })
return return
} }
@@ -108,26 +108,27 @@ const ProjectProfile = ({
className="px-3 py-2 rounded" className="px-3 py-2 rounded"
/> />
</div> </div>
<KeyValue { (project?.description || hasPermissionToEdit) && <KeyValue
keyName="Description" keyName="Description"
hide={!project?.description && !canEdit} edit={{
canEdit={canEdit} hasPermissionToEdit,
onEdit={() => { isEditing,
if (!isEditable) { onEdit: () => {
setIsEditable(true) if (!isEditing) {
} else { setIsEditing(true)
onEditSaveClick() } else {
setIsEditable(false) onEditSaveClick()
} setIsEditing(false)
}
},
}} }}
isEditable={isEditable}
> >
<div <div
id="description-wrap" id="description-wrap"
name="description" name="description"
className={ className={
'markdown-overrides rounded-sm pb-2 mt-2' + 'markdown-overrides rounded-sm pb-2 mt-2' +
(isEditable ? ' min-h-md' : '') (isEditing ? ' min-h-md' : '')
} }
onClick={(e) => onClick={(e) =>
e?.target?.id === 'description-wrap' && e?.target?.id === 'description-wrap' &&
@@ -137,11 +138,11 @@ const ProjectProfile = ({
<Editor <Editor
ref={editorRef} ref={editorRef}
defaultValue={project?.description || ''} defaultValue={project?.description || ''}
readOnly={!isEditable} readOnly={!isEditing}
onChange={onDescriptionChange} onChange={onDescriptionChange}
/> />
</div> </div>
</KeyValue> </KeyValue> }
<div className="grid grid-flow-col-dense gap-6"> <div className="grid grid-flow-col-dense gap-6">
<KeyValue keyName="Created on"> <KeyValue keyName="Created on">
{new Date(project?.createdAt).toDateString()} {new Date(project?.createdAt).toDateString()}
@@ -156,10 +157,11 @@ const ProjectProfile = ({
userEmotes={userEmotes} userEmotes={userEmotes}
onEmote={onReaction} onEmote={onReaction}
onShowProjectReactions={() => setIsReactionsModalOpen(true)} onShowProjectReactions={() => setIsReactionsModalOpen(true)}
className=""
/> />
</KeyValue> </KeyValue>
<KeyValue keyName="Comments" hide={!currentUser}> { currentUser && <KeyValue keyName="Comments">
{!isEditable && ( {!isEditing && (
<> <>
{currentUser && ( {currentUser && (
<> <>
@@ -215,8 +217,8 @@ const ProjectProfile = ({
</ul> </ul>
</> </>
)} )}
</KeyValue> </KeyValue> }
{canEdit && ( {hasPermissionToEdit && (
<> <>
<h4 className="mt-10 text-red-600">Danger Zone</h4> <h4 className="mt-10 text-red-600">Danger Zone</h4>
<Button <Button

View File

@@ -1,45 +1,53 @@
import { useState, useEffect, useRef, useReducer, ReactNode } from 'react' import { useEffect, useReducer } from 'react'
import { useAuth } from '@redwoodjs/auth' import { useAuth } from '@redwoodjs/auth'
import { Link, navigate, routes } from '@redwoodjs/router' import { Link, navigate, routes } from '@redwoodjs/router'
import ProjectsOfUser from 'src/components/ProjectsOfUserCell' import ProjectsOfUser from 'src/components/ProjectsOfUserCell'
import IdeHeader from 'src/components/IdeHeader/IdeHeader' import IdeHeader from 'src/components/IdeHeader/IdeHeader'
import Svg from 'src/components/Svg/Svg' import Svg from 'src/components/Svg/Svg'
import { import {
fieldsConfig, fieldComponents,
fieldReducer, fieldReducer,
UserProfileType, UserProfileType,
FieldConfigType, FieldType,
} from './userProfileConfig' } from './userProfileConfig'
function buildFieldsConfig(fieldsConfig, user) { // This function initializes the state management object for each of the fields
Object.entries(fieldsConfig).forEach( function buildFieldsConfig(fieldsConfig, user, hasPermissionToEdit) {
([key, field]: [string, FieldConfigType]) => { return Object.fromEntries(Object.keys(fieldsConfig).map(
field.currentValue = field.newValue = user[key] (key: string): [string, FieldType] => ([key, {
field.name = key name: key,
} currentValue: user[key],
) newValue: user[key],
isEditing: false,
return fieldsConfig hasPermissionToEdit,
}])
))
} }
const UserProfile = ({ const UserProfile = ({
user, user,
isEditable, isEditing,
loading, loading,
onSave, onSave,
error, error,
projects,
}: UserProfileType) => { }: UserProfileType) => {
const { currentUser } = useAuth() const { currentUser } = useAuth()
const hasEditPermission = currentUser?.sub === user.id const hasPermissionToEdit = currentUser?.sub === user.id
useEffect(() => { useEffect(() => {
isEditable && isEditing &&
!hasEditPermission && !hasPermissionToEdit &&
navigate(routes.user({ userName: user.userName })) navigate(routes.user({ userName: user.userName }))
}, [currentUser]) }, [currentUser])
const initializedFields = buildFieldsConfig(fieldsConfig, user) const initializedFields = buildFieldsConfig(fieldComponents, user, hasPermissionToEdit)
const [fields, fieldDispatch] = useReducer(fieldReducer, initializedFields) const [fields, fieldDispatch] = useReducer(fieldReducer, initializedFields)
const {
name: NameField,
userName: UserNameField,
image: ImageField,
bio: BioField,
createdAt: MemberSinceField,
} = fieldComponents
return ( return (
<> <>
@@ -65,44 +73,45 @@ const UserProfile = ({
{/* Side panel */} {/* Side panel */}
<section className="bg-ch-gray-760 font-fira-sans p-12 md:overflow-y-auto ch-scrollbar"> <section className="bg-ch-gray-760 font-fira-sans p-12 md:overflow-y-auto ch-scrollbar">
<div className="flex gap-6"> <div className="flex gap-6">
{!isEditable && ( {!isEditing && (
<div className="w-28 flex-shrink-0"> <div className="w-28 flex-shrink-0">
<fields.image.component <ImageField
field={fields.image} field={fields.image}
dispatch={fieldDispatch}
user={user} user={user}
save={onSave} save={onSave}
hasEditPermission={hasEditPermission} hasPermissionToEdit={hasPermissionToEdit}
/> />
</div> </div>
)} )}
<div> <div>
<fields.name.component <NameField
field={fields.name} field={fields.name}
dispatch={fieldDispatch} dispatch={fieldDispatch}
user={user} user={user}
save={onSave} save={onSave}
hasEditPermission={hasEditPermission} hasPermissionToEdit={hasPermissionToEdit}
/> />
<fields.userName.component <UserNameField
field={fields.userName} field={fields.userName}
dispatch={fieldDispatch} dispatch={fieldDispatch}
user={user} user={user}
save={onSave} save={onSave}
hasEditPermission={hasEditPermission} hasPermissionToEdit={hasPermissionToEdit}
/> />
</div> </div>
</div> </div>
<div className="mt-10"> <div className="mt-10">
<fields.bio.component <BioField
field={fields.bio} field={fields.bio}
dispatch={fieldDispatch} dispatch={fieldDispatch}
user={user} user={user}
save={onSave} save={onSave}
hasEditPermission={hasEditPermission} hasPermissionToEdit={hasPermissionToEdit}
/> />
</div> </div>
<div className="my-5"> <div className="my-5">
<fields.createdAt.component field={fields.createdAt} /> <MemberSinceField field={fields.createdAt} />
</div> </div>
</section> </section>
{/* Viewer */} {/* Viewer */}

View File

@@ -5,24 +5,42 @@ import Editor from 'rich-markdown-editor'
import ImageUploader from 'src/components/ImageUploader' import ImageUploader from 'src/components/ImageUploader'
import { User } from 'types/graphql' import { User } from 'types/graphql'
export const fieldComponents = {
name: NameField,
userName: UserNameField,
image: ImageField,
bio: BioField,
createdAt: MemberSinceField,
}
export interface UserProfileType { export interface UserProfileType {
user: User user: User
isEditable: boolean isEditing: boolean
loading: boolean loading: boolean
error: boolean error: boolean
onSave: Function onSave: Function
projects: {}[] projects: {}[]
} }
export interface FieldConfigType { export interface FieldType {
name?: string // introspection ugh name: string
editable: boolean currentValue: any
component?: ReactNode newValue: any
needsRef?: boolean isEditing: boolean
isEditing?: boolean | undefined hasPermissionToEdit: boolean
onSave?: Function }
currentValue?: any
newValue?: any export interface FieldComponentPropsType {
field: FieldType
dispatch?: React.Dispatch<Object>
user?: User
save?: Function
hasPermissionToEdit?: boolean
}
interface ProfileKeyValueType extends FieldComponentPropsType {
children: ReactNode
bottom: boolean
} }
const ProfileKeyValue = ({ const ProfileKeyValue = ({
@@ -30,26 +48,27 @@ const ProfileKeyValue = ({
dispatch, dispatch,
user, user,
save, save,
hasEditPermission, hasPermissionToEdit,
children, children,
bottom = false, bottom = false,
}) => { } : ProfileKeyValueType) => {
return ( return (
<KeyValue (user[field.name] && hasPermissionToEdit) && <KeyValue
keyName={field.name} keyName={field.name}
hide={!user[field.name] && !hasEditPermission} edit={{
canEdit={hasEditPermission} hasPermissionToEdit,
onEdit={() => { isEditing: field.isEditing,
if (field.isEditing) { onEdit: () => {
save(user.userName, { [field.name]: field.newValue }) if (field.isEditing && field.currentValue !== field.newValue) {
} save(user.userName, { [field.name]: field.newValue })
dispatch({ }
type: 'SET_CURRENT_VALUE', dispatch({
payload: { field: field.name, value: field.newValue }, type: 'SET_CURRENT_VALUE',
}) payload: { field: field.name, value: field.newValue },
dispatch({ type: 'TOGGLE_EDITING', payload: field.name }) })
dispatch({ type: 'TOGGLE_EDITING', payload: field.name })
},
}} }}
isEditable={hasEditPermission && field.isEditing}
bottom={bottom} bottom={bottom}
className="mb-4" className="mb-4"
> >
@@ -58,148 +77,115 @@ const ProfileKeyValue = ({
) )
} }
const bioField: FieldConfigType = { function BioField(props) {
editable: true, const ref = useRef(null)
needsRef: true, const { field, dispatch } = props
component: (props) => {
const ref = useRef(null)
const { dispatch, field } = props return (
<ProfileKeyValue {...props}>
return ( <div
<ProfileKeyValue {...props}> id="bio-wrap"
<div name="bio"
id="bio-wrap" className={
name="bio" 'markdown-overrides rounded-sm pb-2 mt-2' +
className={ (field.isEditing ? ' min-h-md' : '')
'markdown-overrides rounded-sm pb-2 mt-2' + }
(field.isEditable ? ' min-h-md' : '') onClick={(e) =>
e?.target?.id === 'bio-wrap' && ref?.current?.focusAtEnd()
}
>
<Editor
ref={ref}
defaultValue={field?.currentValue || ''}
readOnly={!field.isEditing}
onChange={(bio) =>
dispatch({
type: 'SET_NEW_VALUE',
payload: { field: 'bio', value: bio() },
})
} }
onClick={(e) => />
e?.target?.id === 'bio-wrap' && ref?.current?.focusAtEnd() </div>
</ProfileKeyValue>
)
}
function MemberSinceField(props : FieldComponentPropsType) {
return (
<KeyValue keyName="Member Since">
<p className="text-ch-gray-300">
{new Date(props.field.currentValue).toLocaleDateString()}
</p>
</KeyValue>
)
}
function ImageField(props : FieldComponentPropsType) {
const { field, user, save, hasPermissionToEdit } = props
return (
<ImageUploader
className="rounded-3xl rounded-tr-none shadow-md border-2 border-ch-gray-300"
onImageUpload={({ cloudinaryPublicId: image }) => {
save(user.userName, {
image,
})
}}
aspectRatio={1}
isEditable={hasPermissionToEdit}
imageUrl={user.image}
width={300}
/>
)
}
function NameField(props : FieldComponentPropsType) {
const { user, dispatch, field } = props
return (
<ProfileKeyValue {...props} bottom={true}>
{!field.isEditing ? (
<h1 className="text-4xl">{user?.name}</h1>
) : (
<InputText
className="text-xl"
value={field.newValue}
onChange={({ target: { value } }) =>
dispatch({
type: 'SET_NEW_VALUE',
payload: { field: 'name', value },
})
} }
> isEditable={field.isEditing}
<Editor />
ref={ref} )}
defaultValue={field?.currentValue || ''} </ProfileKeyValue>
readOnly={!field.isEditing} )
onChange={(bio) =>
dispatch({
type: 'SET_NEW_VALUE',
payload: { field: field.bio, value: bio() },
})
}
/>
</div>
</ProfileKeyValue>
)
},
} }
const createdAtField: FieldConfigType = { function UserNameField(props : FieldComponentPropsType) {
editable: false, const { dispatch, field } = props
component: (props) => {
const { field } = props
return ( return (
<KeyValue keyName="Member Since"> <ProfileKeyValue {...props} bottom={true}>
<p className="text-ch-gray-300"> {!field.isEditing ? (
{new Date(field.currentValue).toLocaleDateString()} <h2 className="text-ch-gray-400">
</p> @{field?.currentValue?.replace(/([^a-zA-Z\d_:])/g, '-')}
</KeyValue> </h2>
) ) : (
}, <InputText
} className="text-xl"
value={field.newValue}
const imageField: FieldConfigType = { onChange={({ target: { value } }) =>
editable: true, dispatch({
component: (props) => { type: 'SET_NEW_VALUE',
const { field, user, save, hasEditPermission } = props payload: { field: 'userName', value },
return ( })
<ImageUploader }
className="rounded-3xl rounded-tr-none shadow-md border-2 border-ch-gray-300" isEditable={field.isEditing}
onImageUpload={({ cloudinaryPublicId: image }) => { />
save(user.userName, { )}
image, </ProfileKeyValue>
}) )
}}
aspectRatio={1}
isEditable={hasEditPermission && !field.isEditing}
imageUrl={user.image}
width={300}
/>
)
},
}
const nameField: FieldConfigType = {
editable: true,
component: (props) => {
const { user, dispatch, field } = props
return (
<ProfileKeyValue {...props} bottom={true}>
{!field.isEditing ? (
<h1 className="text-4xl">{user?.name}</h1>
) : (
<InputText
className="text-xl"
value={field.newValue}
onChange={({ target: { value } }) =>
dispatch({
type: 'SET_NEW_VALUE',
payload: { field: field.name, value },
})
}
isEditable={!field.isEditable}
/>
)}
</ProfileKeyValue>
)
},
}
const userNameField: FieldConfigType = {
editable: true,
component: (props) => {
const { dispatch, field } = props
return (
<ProfileKeyValue {...props} bottom={true}>
{!field.isEditing ? (
<h2 className="text-ch-gray-400">
@{field?.currentValue?.replace(/([^a-zA-Z\d_:])/g, '-')}
</h2>
) : (
<InputText
className="text-xl"
value={field.newValue}
onChange={({ target: { value } }) =>
dispatch({
type: 'SET_NEW_VALUE',
payload: { field: field.name, value },
})
}
isEditable={!field.isEditable}
/>
)}
</ProfileKeyValue>
)
},
}
export const fieldsConfig = {
bio: bioField,
createdAt: createdAtField,
id: {
editable: false,
},
image: imageField,
name: nameField,
updatedAt: {
editable: false,
},
userName: userNameField,
} }
export function fieldReducer(state, action) { export function fieldReducer(state, action) {
@@ -210,7 +196,7 @@ export function fieldReducer(state, action) {
[action.payload]: { [action.payload]: {
...state[action.payload], ...state[action.payload],
isEditing: isEditing:
state[action.payload].editable && !state[action.payload].isEditing state[action.payload].hasPermissionToEdit && !state[action.payload].isEditing
? true ? true
: false, : false,
}, },

View File

@@ -1,11 +1,13 @@
import MainLayout from 'src/layouts/MainLayout' import MainLayout from 'src/layouts/MainLayout'
import EditUserCell from 'src/components/EditUserCell' import EditUserCell from 'src/components/EditUserCell'
import Seo from 'src/components/Seo/Seo' import Seo from 'src/components/Seo/Seo'
import { Toaster } from '@redwoodjs/web/toast'
const UserPage = ({ userName }) => { const UserPage = ({ userName }) => {
return ( return (
<> <>
<Seo title={userName} description="User page" lang="en-US" /> <Seo title={userName} description="User page" lang="en-US" />
<Toaster timeout={9000} />
<EditUserCell userName={userName} /> <EditUserCell userName={userName} />
</> </>