mirror of
https://github.com/fergalmoran/onearmy-community-platform.git
synced 2025-12-22 09:37:54 +00:00
feat: add wrapper to settings form
This commit is contained in:
@@ -7,14 +7,16 @@ import { Flex } from 'theme-ui'
|
||||
import { SettingsFormTab } from './SettingsFormTab'
|
||||
import { SettingsFormTabList } from './SettingsFormTabList'
|
||||
|
||||
import type { ThemeUIStyleObject } from 'theme-ui'
|
||||
import type { ITab } from './SettingsFormTab'
|
||||
|
||||
export interface IProps {
|
||||
tabs: ITab[]
|
||||
sx?: ThemeUIStyleObject | undefined
|
||||
}
|
||||
|
||||
export const SettingsFormWrapper = (props: IProps) => {
|
||||
const { tabs } = props
|
||||
const { sx, tabs } = props
|
||||
const [value, setValue] = useState<number>(0)
|
||||
|
||||
const handleChange = (
|
||||
@@ -25,6 +27,7 @@ export const SettingsFormWrapper = (props: IProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex sx={sx}>
|
||||
<Tabs value={value} onChange={handleChange}>
|
||||
<Flex
|
||||
sx={{
|
||||
@@ -47,5 +50,6 @@ export const SettingsFormWrapper = (props: IProps) => {
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Tabs>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,377 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Form } from 'react-final-form'
|
||||
import { useParams } from 'react-router'
|
||||
import { ARRAY_ERROR, FORM_ERROR } from 'final-form'
|
||||
import arrayMutators from 'final-form-arrays'
|
||||
import { toJS } from 'mobx'
|
||||
import { Button, ExternalLink, Loader, TextNotification } from 'oa-components'
|
||||
import { IModerationStatus } from 'oa-shared'
|
||||
import { UnsavedChangesDialog } from 'src/common/Form/UnsavedChangesDialog'
|
||||
import { Loader, SettingsFormWrapper } from 'oa-components'
|
||||
import { useCommonStores } from 'src/common/hooks/useCommonStores'
|
||||
import { isPreciousPlastic } from 'src/config/config'
|
||||
import { logger } from 'src/logger'
|
||||
import { isModuleSupported, MODULE } from 'src/modules'
|
||||
import { ProfileType } from 'src/modules/profile/types'
|
||||
import { Alert, Box, Card, Flex, Heading, Text } from 'theme-ui'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { Flex, Text } from 'theme-ui'
|
||||
|
||||
import { AccountSettingsSection } from './content/formSections/AccountSettings.section'
|
||||
import { CollectionSection } from './content/formSections/Collection.section'
|
||||
import { EmailNotificationsSection } from './content/formSections/EmailNotifications.section'
|
||||
import { ExpertiseSection } from './content/formSections/Expertise.section'
|
||||
import { FocusSection } from './content/formSections/Focus.section'
|
||||
import { ImpactSection } from './content/formSections/Impact/Impact.section'
|
||||
import { PatreonIntegration } from './content/formSections/PatreonIntegration'
|
||||
import { PublicContactSection } from './content/formSections/PublicContact.section'
|
||||
import { SettingsErrors } from './content/formSections/SettingsErrors'
|
||||
import { SettingsMapPinSection } from './content/formSections/SettingsMapPinSection'
|
||||
import { UserInfosSection } from './content/formSections/UserInfos.section'
|
||||
import { WorkspaceSection } from './content/formSections/Workspace.section'
|
||||
import { ProfileGuidelines } from './content/PostingGuidelines'
|
||||
import { buttons, headings } from './labels'
|
||||
import INITIAL_VALUES from './Template'
|
||||
|
||||
import type { IMapPin } from 'src/models'
|
||||
import type { IUserPP } from 'src/models/userPreciousPlastic.models'
|
||||
|
||||
interface IState {
|
||||
formValues: IUserPP
|
||||
showDeleteDialog?: boolean
|
||||
showLocationDropdown: boolean
|
||||
user?: IUserPP
|
||||
userMapPin: IMapPin | null
|
||||
}
|
||||
|
||||
type INotification = { message: string; icon: string; show: boolean }
|
||||
|
||||
const MapPinModerationComments = (props: { mapPin: IMapPin | null }) => {
|
||||
const { mapPin } = props
|
||||
return mapPin?.comments &&
|
||||
mapPin.moderation == IModerationStatus.IMPROVEMENTS_NEEDED ? (
|
||||
<Alert variant="info" sx={{ mt: 3, fontSize: 2, textAlign: 'left' }}>
|
||||
<Box>
|
||||
This map pin has been marked as requiring further changes. Specifically
|
||||
the moderator comments are:
|
||||
<br />
|
||||
<em>{mapPin?.comments}</em>
|
||||
</Box>
|
||||
</Alert>
|
||||
) : null
|
||||
}
|
||||
|
||||
const WorkspaceMapPinRequiredStars = () => {
|
||||
const { description } = headings.workspace
|
||||
const { themeStore } = useCommonStores().stores
|
||||
|
||||
return (
|
||||
<Alert sx={{ fontSize: 2, textAlign: 'left', my: 2 }} variant="failure">
|
||||
<Box>
|
||||
<ExternalLink
|
||||
href={themeStore?.currentTheme.styles.communityProgramURL}
|
||||
sx={{ textDecoration: 'underline', color: 'currentcolor' }}
|
||||
>
|
||||
{description}
|
||||
</ExternalLink>
|
||||
</Box>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
import { UserProfile } from './content/formSections/UserProfile.section'
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { mapsStore, userStore } = useCommonStores().stores
|
||||
const [state, setState] = useState<IState>({} as any)
|
||||
const [notification, setNotification] = useState<INotification>({
|
||||
message: '',
|
||||
icon: '',
|
||||
show: false,
|
||||
})
|
||||
const [shouldUpdate, setShouldUpdate] = useState<boolean>(true)
|
||||
const { id } = useParams()
|
||||
|
||||
const toggleLocationDropdown = () => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
showLocationDropdown: !prevState.showLocationDropdown,
|
||||
formValues: {
|
||||
...prevState.formValues,
|
||||
mapPinDescription: '',
|
||||
location: null,
|
||||
country: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let user = userStore.user as IUserPP
|
||||
let userMapPin: IMapPin | null = null
|
||||
|
||||
const init = async () => {
|
||||
if (!shouldUpdate) return
|
||||
if (id) {
|
||||
user = await userStore.getUserProfile(id)
|
||||
}
|
||||
|
||||
if (isModuleSupported(MODULE.MAP)) {
|
||||
userMapPin = (await mapsStore.getPin(user.userName)) || null
|
||||
}
|
||||
|
||||
// ensure user form includes all user fields (merge any legacy user with correct format)
|
||||
const baseValues: IUserPP = {
|
||||
...INITIAL_VALUES,
|
||||
// use toJS to avoid mobx monitoring of modified fields (e.g. out of bound arrays on link push)
|
||||
...toJS(user),
|
||||
}
|
||||
const { coverImages, openingHours, links } = baseValues
|
||||
// replace empty arrays with placeholders for filling forms
|
||||
const formValues: IUserPP = {
|
||||
...baseValues,
|
||||
coverImages: new Array(4)
|
||||
.fill(null)
|
||||
.map((v, i) => (coverImages[i] ? coverImages[i] : v)),
|
||||
links: (links.length > 0 ? links : [{} as any]).map((i) => ({
|
||||
...i,
|
||||
key: uuid(),
|
||||
})),
|
||||
openingHours: openingHours!.length > 0 ? openingHours : [{} as any],
|
||||
}
|
||||
|
||||
// remove as updated by sub-form
|
||||
if (formValues.impact) {
|
||||
delete formValues.impact
|
||||
}
|
||||
|
||||
setState({
|
||||
formValues,
|
||||
user,
|
||||
showLocationDropdown: !user?.location?.latlng,
|
||||
userMapPin,
|
||||
})
|
||||
setShouldUpdate(false)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [shouldUpdate])
|
||||
|
||||
const saveProfile = async (values: IUserPP) => {
|
||||
const vals = { ...values }
|
||||
vals.coverImages = (vals.coverImages as any[]).filter((cover) =>
|
||||
cover ? true : false,
|
||||
)
|
||||
// Remove undefined vals from obj before sending to firebase
|
||||
Object.keys(vals).forEach((key) => {
|
||||
if (vals[key] === undefined) {
|
||||
delete vals[key]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
logger.debug({ profile: vals }, 'SettingsPage.saveProfile')
|
||||
await userStore.updateUserProfile(vals, 'settings-save-profile', id)
|
||||
|
||||
setShouldUpdate(true)
|
||||
return setNotification({
|
||||
message: 'Profile Saved',
|
||||
icon: 'check',
|
||||
show: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn({ error, profile: vals }, 'SettingsPage.saveProfile.error')
|
||||
setNotification({ message: 'Save Failed', icon: 'close', show: true })
|
||||
return { [FORM_ERROR]: 'Save Failed' }
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = (v: IUserPP) => {
|
||||
const errors: any = {}
|
||||
// must have at least 1 cover (awkard react final form array format)
|
||||
if (!v.coverImages[0]) {
|
||||
errors.coverImages = []
|
||||
errors.coverImages[ARRAY_ERROR] = 'Must have at least one cover image'
|
||||
}
|
||||
if (!v.links[0]) {
|
||||
errors.links = []
|
||||
errors.links[ARRAY_ERROR] = 'Must have at least one link'
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const { formValues, user, userMapPin } = state
|
||||
const formId = 'userProfileForm'
|
||||
const { userStore } = useCommonStores().stores
|
||||
const user = userStore.activeUser
|
||||
|
||||
return user ? (
|
||||
<Form
|
||||
id={formId}
|
||||
onSubmit={saveProfile}
|
||||
initialValues={formValues}
|
||||
validate={validateForm}
|
||||
mutators={{ ...arrayMutators }}
|
||||
validateOnBlur
|
||||
render={({
|
||||
form,
|
||||
submitFailed,
|
||||
submitting,
|
||||
values,
|
||||
handleSubmit,
|
||||
hasValidationErrors,
|
||||
valid,
|
||||
invalid,
|
||||
errors,
|
||||
}) => {
|
||||
const { createProfile, editProfile } = headings
|
||||
const heading = user.profileType ? editProfile : createProfile
|
||||
const isMember = values.profileType === ProfileType.MEMBER
|
||||
|
||||
return (
|
||||
<Flex mx={-2} bg={'inherit'} sx={{ flexWrap: 'wrap' }}>
|
||||
<UnsavedChangesDialog
|
||||
uploadComplete={userStore.updateStatus.Complete}
|
||||
message={
|
||||
'You are leaving this page without saving. Do you want to continue ?'
|
||||
}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
width: ['100%', '100%', `${(2 / 3) * 100}%`],
|
||||
my: 4,
|
||||
bg: 'inherit',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<form id="userProfileForm" onSubmit={handleSubmit}>
|
||||
<Flex sx={{ flexDirection: 'column' }}>
|
||||
<Card
|
||||
sx={{
|
||||
background: 'softblue',
|
||||
}}
|
||||
>
|
||||
<Flex px={3} py={2}>
|
||||
<Heading as="h1">{heading}</Heading>
|
||||
<SettingsFormWrapper
|
||||
sx={{ maxWidth: '850px', alignSelf: 'center', paddingTop: [1, 5] }}
|
||||
tabs={[
|
||||
{
|
||||
title: 'Profile',
|
||||
header: (
|
||||
<Flex sx={{ gap: 2, flexDirection: 'column' }}>
|
||||
<Text as="h3">✏️ Complete your profile</Text>
|
||||
<Text>
|
||||
In order to post comments or create content, we'd like you to
|
||||
share something about yourself.
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Box
|
||||
sx={{
|
||||
display: ['block', 'block', 'none'],
|
||||
mt: 3,
|
||||
}}
|
||||
>
|
||||
<ProfileGuidelines />
|
||||
</Box>
|
||||
{isModuleSupported(MODULE.MAP) && <FocusSection />}
|
||||
|
||||
{values.profileType === ProfileType.WORKSPACE && (
|
||||
<WorkspaceSection />
|
||||
)}
|
||||
{values.profileType === ProfileType.COLLECTION_POINT && (
|
||||
<CollectionSection
|
||||
required={
|
||||
values.collectedPlasticTypes
|
||||
? values.collectedPlasticTypes.length === 0
|
||||
: true
|
||||
}
|
||||
formValues={values}
|
||||
/>
|
||||
)}
|
||||
{values.profileType === ProfileType.MACHINE_BUILDER && (
|
||||
<ExpertiseSection
|
||||
required={
|
||||
values.machineBuilderXp
|
||||
? values.machineBuilderXp.length === 0
|
||||
: true
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UserInfosSection
|
||||
formValues={values}
|
||||
mutators={form.mutators}
|
||||
showLocationDropdown={state.showLocationDropdown}
|
||||
/>
|
||||
|
||||
{isModuleSupported(MODULE.MAP) && (
|
||||
<SettingsMapPinSection
|
||||
toggleLocationDropdown={toggleLocationDropdown}
|
||||
>
|
||||
<MapPinModerationComments mapPin={userMapPin} />
|
||||
{!isMember && <WorkspaceMapPinRequiredStars />}
|
||||
</SettingsMapPinSection>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{!isMember && isPreciousPlastic() && <ImpactSection />}
|
||||
|
||||
<EmailNotificationsSection
|
||||
notificationSettings={values.notification_settings}
|
||||
/>
|
||||
{!isMember && (
|
||||
<PublicContactSection
|
||||
isContactableByPublic={values.isContactableByPublic}
|
||||
/>
|
||||
)}
|
||||
<PatreonIntegration user={user} />
|
||||
</form>
|
||||
<AccountSettingsSection />
|
||||
</Box>
|
||||
</Flex>
|
||||
{/* desktop guidelines container */}
|
||||
<Flex
|
||||
sx={{
|
||||
width: ['100%', '100%', `${100 / 3}%`],
|
||||
flexDirection: 'column',
|
||||
bg: 'inherit',
|
||||
px: 2,
|
||||
height: 'auto',
|
||||
mt: [0, 0, 4],
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: ['relative', 'relative', 'sticky'],
|
||||
top: 3,
|
||||
maxWidth: ['100%', '100%', '400px'],
|
||||
}}
|
||||
>
|
||||
{isModuleSupported(MODULE.MAP) && (
|
||||
<Box mb={3} sx={{ display: ['none', 'none', 'block'] }}>
|
||||
<ProfileGuidelines />
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
large
|
||||
form={formId}
|
||||
data-cy="save"
|
||||
title={
|
||||
invalid ? `Errors: ${Object.keys(errors || {})}` : 'Submit'
|
||||
}
|
||||
mb={3}
|
||||
sx={{ width: '100%', justifyContent: 'center' }}
|
||||
variant={'primary'}
|
||||
type="submit"
|
||||
// disable button when form invalid or during submit.
|
||||
// ensure enabled after submit error
|
||||
disabled={submitting || (submitFailed && hasValidationErrors)}
|
||||
>
|
||||
{buttons.save}
|
||||
</Button>
|
||||
|
||||
<SettingsErrors
|
||||
errors={errors}
|
||||
isVisible={submitFailed && hasValidationErrors}
|
||||
/>
|
||||
|
||||
{valid && notification.message !== '' && (
|
||||
<TextNotification
|
||||
isVisible={notification.show}
|
||||
variant={valid ? 'success' : 'failure'}
|
||||
>
|
||||
<Text>{buttons.success}</Text>
|
||||
</TextNotification>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}}
|
||||
),
|
||||
body: <UserProfile />,
|
||||
glyph: 'thunderbolt',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Loader />
|
||||
|
||||
@@ -12,7 +12,7 @@ export const AccountSettingsSection = observer(() => {
|
||||
const { description, title } = fields.deleteAccount
|
||||
|
||||
return (
|
||||
<Card sx={{ padding: 4, marginTop: 4 }}>
|
||||
<Card sx={{ padding: 4 }}>
|
||||
<Flex
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SelectField } from 'src/common/Form/Select.field'
|
||||
import { buttons, fields } from 'src/pages/UserSettings/labels'
|
||||
import { formatLink } from 'src/utils/formatters'
|
||||
import { required, validateEmail, validateUrl } from 'src/utils/validators'
|
||||
import { Box, Flex, Grid } from 'theme-ui'
|
||||
import { Flex } from 'theme-ui'
|
||||
|
||||
const COM_TYPE_MOCKS = [
|
||||
{
|
||||
@@ -84,7 +84,6 @@ export const ProfileLinkField = (props: IProps) => {
|
||||
variant={'outline'}
|
||||
showIconOnly={true}
|
||||
onClick={() => toggleDeleteModal()}
|
||||
ml={2}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
@@ -92,11 +91,12 @@ export const ProfileLinkField = (props: IProps) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex my={[2]} sx={{ flexDirection: ['column', 'column', 'row'] }}>
|
||||
<Grid mb={[1, 1, 0]} gap={0} sx={{ width: ['100%', '100%', '210px'] }}>
|
||||
<Box
|
||||
<Flex
|
||||
sx={{
|
||||
mr: 2,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: [1, 2],
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
@@ -110,24 +110,8 @@ export const ProfileLinkField = (props: IProps) => {
|
||||
placeholder={buttons.link.type}
|
||||
validate={required}
|
||||
validateFields={[]}
|
||||
style={{ width: '100%', height: '40px' }}
|
||||
style={{ flex: 3 }}
|
||||
/>
|
||||
</Box>
|
||||
{isDeleteEnabled ? (
|
||||
<DeleteButton
|
||||
sx={{
|
||||
display: ['block', 'block', 'none'],
|
||||
}}
|
||||
ml={'2px'}
|
||||
/>
|
||||
) : null}
|
||||
</Grid>
|
||||
<Grid
|
||||
mb={[1, 1, 0]}
|
||||
gap={0}
|
||||
columns={['auto', 'auto', 'auto']}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<Field
|
||||
data-cy={`input-link-${index}`}
|
||||
name={`${name}.url`}
|
||||
@@ -137,16 +121,9 @@ export const ProfileLinkField = (props: IProps) => {
|
||||
placeholder={fields.links.placeholder}
|
||||
format={(v) => formatLink(v, state.linkType)}
|
||||
formatOnBlur={true}
|
||||
style={{ width: '100%', height: '40px', marginBottom: '0px' }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
{isDeleteEnabled ? (
|
||||
<DeleteButton
|
||||
sx={{
|
||||
display: ['none', 'none', 'block'],
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isDeleteEnabled ? <DeleteButton /> : null}
|
||||
{
|
||||
<ConfirmModal
|
||||
isOpen={!!state.showDeleteModal}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field } from 'react-final-form'
|
||||
import { useTheme } from '@emotion/react'
|
||||
import { Button, ExternalLink } from 'oa-components'
|
||||
import { ExternalLink } from 'oa-components'
|
||||
import { getSupportedProfileTypes } from 'src/modules/profile'
|
||||
import { buttons, fields, headings } from 'src/pages/UserSettings/labels'
|
||||
import { Box, Flex, Grid, Heading, Paragraph, Text } from 'theme-ui'
|
||||
@@ -11,7 +11,7 @@ import { FlexSectionContainer } from './elements'
|
||||
import type { ProfileTypeLabel } from 'src/modules/profile/types'
|
||||
|
||||
const ProfileTypes = () => {
|
||||
const { description, error, title } = fields.activities
|
||||
const { description, error } = fields.activities
|
||||
const theme = useTheme()
|
||||
const profileTypes = getSupportedProfileTypes().filter(({ label }) =>
|
||||
Object.keys(theme.badges).includes(label),
|
||||
@@ -31,8 +31,21 @@ const ProfileTypes = () => {
|
||||
{headings.focus}
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
||||
<Flex sx={{ alignItems: 'baseline', marginY: 2 }}>
|
||||
<Paragraph my={4}>
|
||||
{description}{' '}
|
||||
<ExternalLink
|
||||
href={theme.profileGuidelinesURL}
|
||||
sx={{ textDecoration: 'underline', color: 'grey' }}
|
||||
type="button"
|
||||
>
|
||||
{buttons.guidelines}
|
||||
</ExternalLink>
|
||||
</Paragraph>
|
||||
</Flex>
|
||||
|
||||
<Box>
|
||||
<Paragraph my={4}>{title}</Paragraph>
|
||||
<Grid columns={['repeat(auto-fill, minmax(125px, 1fr))']} gap={2}>
|
||||
{profileTypes.map((profile, index: number) => (
|
||||
<Box key={index}>
|
||||
@@ -52,14 +65,6 @@ const ProfileTypes = () => {
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
<Flex sx={{ flexWrap: 'wrap', alignItems: 'center' }} mt={4}>
|
||||
<Text my={2}>{description}</Text>
|
||||
<ExternalLink href={theme.profileGuidelinesURL}>
|
||||
<Button ml={[1, 2, 2]} variant="outline" type="button">
|
||||
{buttons.guidelines}
|
||||
</Button>
|
||||
</ExternalLink>
|
||||
</Flex>
|
||||
{props.meta.error && <Text color={theme.colors.red}>{error}</Text>}
|
||||
</Box>
|
||||
</FlexSectionContainer>
|
||||
|
||||
@@ -186,7 +186,7 @@ export const UserInfosSection = (props: IProps) => {
|
||||
/>
|
||||
</Flex>
|
||||
<Box data-cy="UserInfos: links">
|
||||
<Flex sx={{ alignItems: 'center', width: '100%', wrap: 'nowrap' }}>
|
||||
<Flex>
|
||||
<Text mb={2} mt={7} sx={{ fontSize: 2 }}>
|
||||
{`${fields.links.title} *`}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Form } from 'react-final-form'
|
||||
import { useParams } from 'react-router'
|
||||
import { ARRAY_ERROR, FORM_ERROR } from 'final-form'
|
||||
import arrayMutators from 'final-form-arrays'
|
||||
import { toJS } from 'mobx'
|
||||
import { Button, ExternalLink, TextNotification } from 'oa-components'
|
||||
import { IModerationStatus } from 'oa-shared'
|
||||
import { UnsavedChangesDialog } from 'src/common/Form/UnsavedChangesDialog'
|
||||
import { useCommonStores } from 'src/common/hooks/useCommonStores'
|
||||
import { isPreciousPlastic } from 'src/config/config'
|
||||
import { logger } from 'src/logger'
|
||||
import { isModuleSupported, MODULE } from 'src/modules'
|
||||
import { ProfileType } from 'src/modules/profile/types'
|
||||
import { Alert, Box, Flex, Text } from 'theme-ui'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { buttons, headings } from '../../labels'
|
||||
import INITIAL_VALUES from '../../Template'
|
||||
import { ImpactSection } from './Impact/Impact.section'
|
||||
import { AccountSettingsSection } from './AccountSettings.section'
|
||||
import { CollectionSection } from './Collection.section'
|
||||
import { FlexSectionContainer } from './elements'
|
||||
import { EmailNotificationsSection } from './EmailNotifications.section'
|
||||
import { ExpertiseSection } from './Expertise.section'
|
||||
import { FocusSection } from './Focus.section'
|
||||
import { PatreonIntegration } from './PatreonIntegration'
|
||||
import { PublicContactSection } from './PublicContact.section'
|
||||
import { SettingsErrors } from './SettingsErrors'
|
||||
import { SettingsMapPinSection } from './SettingsMapPinSection'
|
||||
import { UserInfosSection } from './UserInfos.section'
|
||||
import { WorkspaceSection } from './Workspace.section'
|
||||
|
||||
import type { IMapPin } from 'src/models'
|
||||
import type { IUserPP } from 'src/models/userPreciousPlastic.models'
|
||||
|
||||
interface IState {
|
||||
formValues: IUserPP
|
||||
showDeleteDialog?: boolean
|
||||
showLocationDropdown: boolean
|
||||
user?: IUserPP
|
||||
userMapPin: IMapPin | null
|
||||
}
|
||||
|
||||
type INotification = {
|
||||
message: string
|
||||
icon: string
|
||||
show: boolean
|
||||
variant: 'success' | 'failure'
|
||||
}
|
||||
|
||||
const MapPinModerationComments = (props: { mapPin: IMapPin | null }) => {
|
||||
const { mapPin } = props
|
||||
return mapPin?.comments &&
|
||||
mapPin.moderation == IModerationStatus.IMPROVEMENTS_NEEDED ? (
|
||||
<Alert variant="info" sx={{ mt: 3, fontSize: 2, textAlign: 'left' }}>
|
||||
<Box>
|
||||
This map pin has been marked as requiring further changes. Specifically
|
||||
the moderator comments are:
|
||||
<br />
|
||||
<em>{mapPin?.comments}</em>
|
||||
</Box>
|
||||
</Alert>
|
||||
) : null
|
||||
}
|
||||
|
||||
const WorkspaceMapPinRequiredStars = () => {
|
||||
const { description } = headings.workspace
|
||||
const { themeStore } = useCommonStores().stores
|
||||
|
||||
return (
|
||||
<Alert sx={{ fontSize: 2, textAlign: 'left', my: 2 }} variant="failure">
|
||||
<Box>
|
||||
<ExternalLink
|
||||
href={themeStore?.currentTheme.styles.communityProgramURL}
|
||||
sx={{ textDecoration: 'underline', color: 'currentcolor' }}
|
||||
>
|
||||
{description}
|
||||
</ExternalLink>
|
||||
</Box>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserProfile = () => {
|
||||
const { mapsStore, userStore } = useCommonStores().stores
|
||||
const [state, setState] = useState<IState>({} as any)
|
||||
const [notification, setNotification] = useState<INotification>({
|
||||
message: '',
|
||||
icon: '',
|
||||
show: false,
|
||||
variant: 'success',
|
||||
})
|
||||
const [shouldUpdate, setShouldUpdate] = useState<boolean>(true)
|
||||
const { id } = useParams()
|
||||
|
||||
const toggleLocationDropdown = () => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
showLocationDropdown: !prevState.showLocationDropdown,
|
||||
formValues: {
|
||||
...prevState.formValues,
|
||||
mapPinDescription: '',
|
||||
location: null,
|
||||
country: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let user = userStore.user as IUserPP
|
||||
let userMapPin: IMapPin | null = null
|
||||
|
||||
const init = async () => {
|
||||
if (!shouldUpdate) return
|
||||
if (id) {
|
||||
user = await userStore.getUserProfile(id)
|
||||
}
|
||||
|
||||
if (isModuleSupported(MODULE.MAP)) {
|
||||
userMapPin = (await mapsStore.getPin(user.userName)) || null
|
||||
}
|
||||
|
||||
// ensure user form includes all user fields (merge any legacy user with correct format)
|
||||
const baseValues: IUserPP = {
|
||||
...INITIAL_VALUES,
|
||||
// use toJS to avoid mobx monitoring of modified fields (e.g. out of bound arrays on link push)
|
||||
...toJS(user),
|
||||
}
|
||||
const { coverImages, openingHours, links } = baseValues
|
||||
// replace empty arrays with placeholders for filling forms
|
||||
const formValues: IUserPP = {
|
||||
...baseValues,
|
||||
coverImages: new Array(4)
|
||||
.fill(null)
|
||||
.map((v, i) => (coverImages[i] ? coverImages[i] : v)),
|
||||
links: (links.length > 0 ? links : [{} as any]).map((i) => ({
|
||||
...i,
|
||||
key: uuid(),
|
||||
})),
|
||||
openingHours: openingHours!.length > 0 ? openingHours : [{} as any],
|
||||
}
|
||||
|
||||
// remove as updated by sub-form
|
||||
if (formValues.impact) {
|
||||
delete formValues.impact
|
||||
}
|
||||
|
||||
setState({
|
||||
formValues,
|
||||
user,
|
||||
showLocationDropdown: !user?.location?.latlng,
|
||||
userMapPin,
|
||||
})
|
||||
setShouldUpdate(false)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [shouldUpdate])
|
||||
|
||||
const saveProfile = async (values: IUserPP) => {
|
||||
window.scrollTo(0, 0)
|
||||
const vals = { ...values }
|
||||
vals.coverImages = (vals.coverImages as any[]).filter((cover) =>
|
||||
cover ? true : false,
|
||||
)
|
||||
// Remove undefined vals from obj before sending to firebase
|
||||
Object.keys(vals).forEach((key) => {
|
||||
if (vals[key] === undefined) {
|
||||
delete vals[key]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
logger.debug({ profile: vals }, 'SettingsPage.saveProfile')
|
||||
await userStore.updateUserProfile(vals, 'settings-save-profile', id)
|
||||
|
||||
setShouldUpdate(true)
|
||||
return setNotification({
|
||||
message: 'Profile Saved',
|
||||
icon: 'check',
|
||||
show: true,
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn({ error, profile: vals }, 'SettingsPage.saveProfile.error')
|
||||
setNotification({
|
||||
message: 'Save Failed',
|
||||
icon: 'close',
|
||||
show: true,
|
||||
variant: 'failure',
|
||||
})
|
||||
return { [FORM_ERROR]: 'Save Failed' }
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = (v: IUserPP) => {
|
||||
const errors: any = {}
|
||||
// must have at least 1 cover (awkard react final form array format)
|
||||
if (!v.coverImages[0]) {
|
||||
errors.coverImages = []
|
||||
errors.coverImages[ARRAY_ERROR] = 'Must have at least one cover image'
|
||||
}
|
||||
if (!v.links[0]) {
|
||||
errors.links = []
|
||||
errors.links[ARRAY_ERROR] = 'Must have at least one link'
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const { formValues, user, userMapPin } = state
|
||||
const formId = 'userProfileForm'
|
||||
|
||||
return (
|
||||
user && (
|
||||
<Form
|
||||
id={formId}
|
||||
onSubmit={saveProfile}
|
||||
initialValues={formValues}
|
||||
validate={validateForm}
|
||||
mutators={{ ...arrayMutators }}
|
||||
validateOnBlur
|
||||
render={({
|
||||
form,
|
||||
submitFailed,
|
||||
submitting,
|
||||
touched,
|
||||
values,
|
||||
handleSubmit,
|
||||
hasValidationErrors,
|
||||
invalid,
|
||||
errors,
|
||||
pristine,
|
||||
}) => {
|
||||
const isMember = values.profileType === ProfileType.MEMBER
|
||||
|
||||
return (
|
||||
<Flex
|
||||
bg={'inherit'}
|
||||
sx={{ flexDirection: 'column', padding: 4, gap: 4 }}
|
||||
>
|
||||
<UnsavedChangesDialog
|
||||
uploadComplete={userStore.updateStatus.Complete}
|
||||
message={
|
||||
'You are leaving this page without saving. Do you want to continue ?'
|
||||
}
|
||||
/>
|
||||
|
||||
<>
|
||||
{!!notification.show && pristine && !touched && (
|
||||
<TextNotification
|
||||
isVisible={notification.show}
|
||||
variant={notification.variant}
|
||||
>
|
||||
<Text>{buttons.success}</Text>
|
||||
</TextNotification>
|
||||
)}
|
||||
{(errors || submitFailed) && touched && !pristine && (
|
||||
<SettingsErrors
|
||||
errors={errors}
|
||||
isVisible={!!(errors && Object.keys(errors).length > 0)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
<form id="userProfileForm" onSubmit={handleSubmit}>
|
||||
<Flex sx={{ flexDirection: 'column', gap: 4 }}>
|
||||
{isModuleSupported(MODULE.MAP) && <FocusSection />}
|
||||
|
||||
{values.profileType === ProfileType.WORKSPACE && (
|
||||
<WorkspaceSection />
|
||||
)}
|
||||
|
||||
{values.profileType === ProfileType.COLLECTION_POINT && (
|
||||
<CollectionSection
|
||||
required={
|
||||
values.collectedPlasticTypes
|
||||
? values.collectedPlasticTypes.length === 0
|
||||
: true
|
||||
}
|
||||
formValues={values}
|
||||
/>
|
||||
)}
|
||||
{values.profileType === ProfileType.MACHINE_BUILDER && (
|
||||
<ExpertiseSection
|
||||
required={
|
||||
values.machineBuilderXp
|
||||
? values.machineBuilderXp.length === 0
|
||||
: true
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UserInfosSection
|
||||
formValues={values}
|
||||
mutators={form.mutators}
|
||||
showLocationDropdown={state.showLocationDropdown}
|
||||
/>
|
||||
|
||||
{isModuleSupported(MODULE.MAP) && (
|
||||
<SettingsMapPinSection
|
||||
toggleLocationDropdown={toggleLocationDropdown}
|
||||
>
|
||||
<MapPinModerationComments mapPin={userMapPin} />
|
||||
{!isMember && <WorkspaceMapPinRequiredStars />}
|
||||
</SettingsMapPinSection>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{!isMember && isPreciousPlastic() && (
|
||||
<FlexSectionContainer>
|
||||
<ImpactSection />
|
||||
</FlexSectionContainer>
|
||||
)}
|
||||
|
||||
<EmailNotificationsSection
|
||||
notificationSettings={values.notification_settings}
|
||||
/>
|
||||
{!isMember && (
|
||||
<FlexSectionContainer>
|
||||
<PublicContactSection
|
||||
isContactableByPublic={values.isContactableByPublic}
|
||||
/>
|
||||
</FlexSectionContainer>
|
||||
)}
|
||||
<PatreonIntegration user={user} />
|
||||
</form>
|
||||
|
||||
<AccountSettingsSection />
|
||||
|
||||
<Button
|
||||
large
|
||||
form={formId}
|
||||
data-cy="save"
|
||||
title={
|
||||
invalid ? `Errors: ${Object.keys(errors || {})}` : 'Submit'
|
||||
}
|
||||
variant={'primary'}
|
||||
type="submit"
|
||||
// disable button when form invalid or during submit.
|
||||
// ensure enabled after submit error
|
||||
disabled={submitting || (submitFailed && hasValidationErrors)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{buttons.save}
|
||||
</Button>
|
||||
</Flex>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { FactoryUser } from 'src/test/factories/User'
|
||||
import { testingThemeStyles } from 'src/test/utils/themeUtils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SettingsPage } from './SettingsPage'
|
||||
import { UserProfile } from './UserProfile.section'
|
||||
|
||||
import type { IUserPPDB } from 'src/models'
|
||||
|
||||
@@ -69,21 +69,6 @@ describe('UserSettings', () => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('displays user settings', async () => {
|
||||
mockUser = FactoryUser()
|
||||
|
||||
// Act
|
||||
let wrapper
|
||||
act(() => {
|
||||
wrapper = Wrapper(mockUser)
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(wrapper.getByText('Edit profile'))
|
||||
})
|
||||
})
|
||||
|
||||
it('displays one photo for member', async () => {
|
||||
mockUser = FactoryUser({ profileType: 'member' })
|
||||
// Act
|
||||
@@ -269,7 +254,7 @@ const Wrapper = (user: IUserPPDB, routerInitialEntry?: string) => {
|
||||
}
|
||||
|
||||
const router = createMemoryRouter(
|
||||
createRoutesFromElements(<Route index element={<SettingsPage />} />),
|
||||
createRoutesFromElements(<Route index element={<UserProfile />} />),
|
||||
{
|
||||
initialEntries: [routerInitialEntry ? routerInitialEntry : ''],
|
||||
},
|
||||
@@ -7,7 +7,7 @@ import Sheetpress from 'src/assets/images/workspace-focus/sheetpress.png'
|
||||
import Shredder from 'src/assets/images/workspace-focus/shredder.png'
|
||||
import { fields } from 'src/pages/UserSettings/labels'
|
||||
import { required } from 'src/utils/validators'
|
||||
import { Box, Flex, Heading, Text } from 'theme-ui'
|
||||
import { Box, Flex, Grid, Heading, Text } from 'theme-ui'
|
||||
|
||||
import { CustomRadioField } from './Fields/CustomRadio.field'
|
||||
import { FlexSectionContainer } from './elements'
|
||||
@@ -66,7 +66,7 @@ export const WorkspaceSection = () => {
|
||||
<Text mt={4} mb={4}>
|
||||
{description}
|
||||
</Text>
|
||||
<Flex sx={{ flexWrap: ['wrap', 'wrap', 'nowrap'] }}>
|
||||
<Grid columns={['repeat(auto-fill, minmax(125px, 1fr))']} gap={2}>
|
||||
{WORKSPACE_TYPES.map((workspace, index: number) => (
|
||||
<CustomRadioField
|
||||
data-cy={workspace.label}
|
||||
@@ -83,7 +83,7 @@ export const WorkspaceSection = () => {
|
||||
subText={workspace.subText}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Grid>
|
||||
{meta.touched && meta.error && (
|
||||
<Text
|
||||
sx={{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field } from 'react-final-form'
|
||||
import styled from '@emotion/styled'
|
||||
import { Card, Flex } from 'theme-ui'
|
||||
import { Flex } from 'theme-ui'
|
||||
|
||||
export const Label = (props) => (
|
||||
<Flex
|
||||
@@ -36,9 +36,5 @@ export const HiddenInput = styled(Field)`
|
||||
`
|
||||
|
||||
export const FlexSectionContainer = (props) => (
|
||||
<Card mt={4} style={{ overflow: 'visible' }}>
|
||||
<Flex p={4} sx={{ flexWrap: 'nowrap', flexDirection: 'column' }}>
|
||||
{props.children}
|
||||
</Flex>
|
||||
</Card>
|
||||
<Flex sx={{ flexDirection: 'column', paddingY: 2 }}>{props.children}</Flex>
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ export const buttons = {
|
||||
|
||||
export const fields: ILabels = {
|
||||
activities: {
|
||||
description: 'Not sure about your focus?',
|
||||
description: 'Choose your main activity. Not sure?',
|
||||
error: 'Please select a focus',
|
||||
title: 'What is your main activity?',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user