-
-
Notifications
You must be signed in to change notification settings - Fork 167
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4709 from kobotoolbox/split-out-account-fields-ed…
…itor [TASK-114] Split out AccountFieldsEditor from AccountSettingsRoute
- Loading branch information
Showing
12 changed files
with
570 additions
and
334 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
/** | ||
* This is a list of user metadata fields known by Front end code. If you happen | ||
* to add new fields, please updated this interface first :) | ||
*/ | ||
export interface AccountFieldsValues { | ||
name: string; | ||
organization: string; | ||
organization_website: string; | ||
sector: string; | ||
gender: string; | ||
bio: string; | ||
city: string; | ||
country: string; | ||
require_auth: boolean; | ||
twitter: string; | ||
linkedin: string; | ||
instagram: string; | ||
} | ||
|
||
export type AccountFieldsErrors = {[name in UserFieldName]?: string}; | ||
|
||
export type UserFieldName = keyof AccountFieldsValues; | ||
|
||
export const USER_FIELD_NAMES: Record<UserFieldName, UserFieldName> = { | ||
name: 'name', | ||
organization: 'organization', | ||
organization_website: 'organization_website', | ||
sector: 'sector', | ||
gender: 'gender', | ||
bio: 'bio', | ||
city: 'city', | ||
country: 'country', | ||
require_auth: 'require_auth', | ||
twitter: 'twitter', | ||
linkedin: 'linkedin', | ||
instagram: 'instagram', | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import type {AccountFieldsValues} from './account.constants'; | ||
import envStore from '../envStore'; | ||
import {USER_FIELD_NAMES} from './account.constants'; | ||
|
||
export function getInitialAccountFieldsValues(): AccountFieldsValues { | ||
return { | ||
name: '', | ||
organization: '', | ||
organization_website: '', | ||
sector: '', | ||
gender: '', | ||
bio: '', | ||
city: '', | ||
country: '', | ||
require_auth: false, | ||
twitter: '', | ||
linkedin: '', | ||
instagram: '', | ||
}; | ||
} | ||
|
||
/** | ||
* For given field values produces an object to use with the `/me` endpoint for | ||
* updating the `extra_details`. | ||
*/ | ||
export function getProfilePatchData(fields: AccountFieldsValues) { | ||
// HACK: dumb down the `output` type here, so TS doesn't have a problem with | ||
// types inside the `forEach` loop below, and the output is compatible with | ||
// functions from `api.ts` file. | ||
const output: {extra_details: {[key: string]: any}} = { | ||
extra_details: getInitialAccountFieldsValues(), | ||
}; | ||
|
||
// To patch correctly with recent changes to the backend, | ||
// ensure that we send empty strings if the field is left blank. | ||
|
||
// We should only overwrite user metadata that the user can see. | ||
// Fields that: | ||
// (a) are enabled in constance | ||
// (b) the frontend knows about | ||
|
||
// Make a list of user metadata fields to include in the patch | ||
const presentMetadataFields = | ||
// Fields enabled in constance | ||
envStore.data | ||
.getUserMetadataFieldNames() | ||
// Intersected with: | ||
.filter( | ||
(fieldName) => | ||
// Fields the frontend knows about | ||
fieldName in USER_FIELD_NAMES | ||
); | ||
|
||
// Populate the patch with user form input, or empty strings. | ||
presentMetadataFields.forEach((fieldName) => { | ||
output.extra_details[fieldName] = fields[fieldName]; | ||
}); | ||
|
||
// Always include require_auth, defaults to 'false'. | ||
output.extra_details.require_auth = fields.require_auth ? true : false; | ||
|
||
return output; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,292 @@ | ||
import React from 'react'; | ||
import Checkbox from '../components/common/checkbox'; | ||
import TextBox from '../components/common/textBox'; | ||
import {addRequiredToLabel} from 'js/textUtils'; | ||
import envStore from '../envStore'; | ||
import styles from './accountFieldsEditor.module.scss'; | ||
import KoboSelect from 'js/components/common/koboSelect'; | ||
import type { | ||
UserFieldName, | ||
AccountFieldsValues, | ||
AccountFieldsErrors, | ||
} from './account.constants'; | ||
|
||
const GENDER_SELECT_OPTIONS = [ | ||
{value: 'male', label: t('Male')}, | ||
{value: 'female', label: t('Female')}, | ||
{value: 'other', label: t('Other')}, | ||
]; | ||
|
||
type UserFieldValue = string | boolean; | ||
|
||
interface AccountFieldsEditorProps { | ||
/** | ||
* A list of fields to display in editor. Regardless of this list, all | ||
* the fields values will be returned with `onChange` callback (to avoid | ||
* losing data). If this is not provided, we display all fields :) | ||
*/ | ||
displayedFields?: UserFieldName[]; | ||
/** Errors to be displayed for fields */ | ||
errors?: AccountFieldsErrors; | ||
/** | ||
* We need values for all fields, even if only few are displayed (via | ||
* `displayedFields` prop) | ||
*/ | ||
values: AccountFieldsValues; | ||
onChange: (fields: AccountFieldsValues) => void; | ||
} | ||
|
||
/** | ||
* A component that displays fields from user account and allows editing their | ||
* values. It DOES NOT handle the API calls to update the values on the endpoint. | ||
*/ | ||
export default function AccountFieldsEditor(props: AccountFieldsEditorProps) { | ||
if (!envStore.isReady) { | ||
return null; | ||
} | ||
|
||
const metadata = envStore.data.getUserMetadataFieldsAsSimpleDict(); | ||
|
||
/** Get label and (required) for a given user metadata fieldname */ | ||
function getLabel(fieldName: UserFieldName): string { | ||
const label = | ||
metadata[fieldName]?.label || | ||
(console.error(`No label for fieldname "${fieldName}"`), fieldName); | ||
const required = metadata[fieldName]?.required || false; | ||
return addRequiredToLabel(label, required); | ||
} | ||
|
||
function onAnyFieldChange( | ||
fieldName: UserFieldName, | ||
newValue: UserFieldValue | ||
) { | ||
const newValues = {...props.values, [fieldName]: newValue}; | ||
props.onChange(newValues); | ||
} | ||
|
||
/** | ||
* Field will be displayed if it's enabled on Back end and it's not omitted | ||
* in `displayedFields`. | ||
*/ | ||
function isFieldToBeDisplayed(name: UserFieldName) { | ||
return ( | ||
// Check if field is enabled by Back-end configuration | ||
name in metadata && | ||
// Check if parent code is not limiting displayed fields to a selection | ||
(!props.displayedFields || | ||
// Check if parent code is limiting displayed fields to a selection, and | ||
// that selection includes the field | ||
props.displayedFields.includes(name)) | ||
); | ||
} | ||
|
||
return ( | ||
<div> | ||
<div className={styles.row}> | ||
{/* Privacy */} | ||
{isFieldToBeDisplayed('require_auth') && ( | ||
<div className={styles.field}> | ||
<label>{t('Privacy')}</label> | ||
|
||
<Checkbox | ||
checked={props.values.require_auth} | ||
onChange={(isChecked: boolean) => | ||
onAnyFieldChange('require_auth', isChecked) | ||
} | ||
label={t('Require authentication to see forms and submit data')} | ||
/> | ||
</div> | ||
)} | ||
</div> | ||
|
||
<div className={styles.row}> | ||
{/* Full name */} | ||
{isFieldToBeDisplayed('name') && ( | ||
<div className={styles.field}> | ||
<TextBox | ||
label={getLabel('name')} | ||
onChange={onAnyFieldChange.bind(onAnyFieldChange, 'name')} | ||
value={props.values.name} | ||
errors={props.errors?.name} | ||
placeholder={t( | ||
'Use this to display your real name to other users' | ||
)} | ||
/> | ||
</div> | ||
)} | ||
|
||
{/* Gender */} | ||
{isFieldToBeDisplayed('gender') && ( | ||
<div className={styles.field}> | ||
<KoboSelect | ||
label={getLabel('gender')} | ||
name='gender' | ||
type='outline' | ||
size='l' | ||
isClearable | ||
isSearchable | ||
selectedOption={props.values.gender} | ||
onChange={(value: string | null) => | ||
onAnyFieldChange('gender', value || '') | ||
} | ||
options={GENDER_SELECT_OPTIONS} | ||
error={props.errors?.gender} | ||
/> | ||
</div> | ||
)} | ||
</div> | ||
|
||
<div className={styles.row}> | ||
{/* Country */} | ||
{isFieldToBeDisplayed('country') && ( | ||
<div className={styles.field}> | ||
<KoboSelect | ||
label={getLabel('country')} | ||
name='country' | ||
type='outline' | ||
size='l' | ||
isClearable | ||
isSearchable | ||
selectedOption={props.values.country} | ||
onChange={(value: string | null) => | ||
onAnyFieldChange('country', value || '') | ||
} | ||
options={envStore.data.country_choices} | ||
error={props.errors?.country} | ||
/> | ||
</div> | ||
)} | ||
|
||
{/* City */} | ||
{isFieldToBeDisplayed('city') && ( | ||
<div className={styles.field}> | ||
<TextBox | ||
label={getLabel('city')} | ||
value={props.values.city} | ||
onChange={onAnyFieldChange.bind(onAnyFieldChange, 'city')} | ||
errors={props.errors?.city} | ||
/> | ||
</div> | ||
)} | ||
</div> | ||
|
||
<div className={styles.row}> | ||
{/* Organization */} | ||
{isFieldToBeDisplayed('organization') && ( | ||
<div className={styles.field}> | ||
<TextBox | ||
label={getLabel('organization')} | ||
onChange={onAnyFieldChange.bind(onAnyFieldChange, 'organization')} | ||
value={props.values.organization} | ||
errors={props.errors?.organization} | ||
/> | ||
</div> | ||
)} | ||
|
||
{/* Organization Website */} | ||
{isFieldToBeDisplayed('organization_website') && ( | ||
<div className={styles.field}> | ||
<TextBox | ||
label={getLabel('organization_website')} | ||
value={props.values.organization_website} | ||
onChange={onAnyFieldChange.bind( | ||
onAnyFieldChange, | ||
'organization_website' | ||
)} | ||
errors={props.errors?.organization_website} | ||
/> | ||
</div> | ||
)} | ||
</div> | ||
|
||
<div className={styles.row}> | ||
{/* Primary Sector */} | ||
{isFieldToBeDisplayed('sector') && ( | ||
<div className={styles.field}> | ||
<KoboSelect | ||
label={getLabel('sector')} | ||
name='sector' | ||
type='outline' | ||
size='l' | ||
isClearable | ||
isSearchable | ||
selectedOption={props.values.sector} | ||
onChange={(value: string | null) => | ||
onAnyFieldChange('sector', value || '') | ||
} | ||
options={envStore.data.sector_choices} | ||
error={props.errors?.sector} | ||
/> | ||
</div> | ||
)} | ||
</div> | ||
|
||
<div className={styles.row}> | ||
{/* Bio */} | ||
{isFieldToBeDisplayed('bio') && ( | ||
<div className={styles.field}> | ||
<TextBox | ||
label={getLabel('bio')} | ||
value={props.values.bio} | ||
onChange={onAnyFieldChange.bind(onAnyFieldChange, 'bio')} | ||
errors={props.errors?.bio} | ||
/> | ||
</div> | ||
)} | ||
</div> | ||
|
||
<div className={styles.row}> | ||
{/* Social */} | ||
{(isFieldToBeDisplayed('twitter') || | ||
isFieldToBeDisplayed('linkedin') || | ||
isFieldToBeDisplayed('instagram')) && ( | ||
<> | ||
<div className={styles.socialLabel}>{t('Social')}</div> | ||
|
||
{/* Twitter */} | ||
{isFieldToBeDisplayed('twitter') && ( | ||
<div className={styles.field}> | ||
<TextBox | ||
startIcon='logo-twitter' | ||
placeholder={getLabel('twitter')} | ||
value={props.values.twitter} | ||
onChange={onAnyFieldChange.bind(onAnyFieldChange, 'twitter')} | ||
errors={props.errors?.twitter} | ||
/> | ||
</div> | ||
)} | ||
|
||
{/* LinkedIn */} | ||
{isFieldToBeDisplayed('linkedin') && ( | ||
<div className={styles.field}> | ||
<TextBox | ||
startIcon='logo-linkedin' | ||
placeholder={getLabel('linkedin')} | ||
value={props.values.linkedin} | ||
onChange={onAnyFieldChange.bind(onAnyFieldChange, 'linkedin')} | ||
errors={props.errors?.linkedin} | ||
/> | ||
</div> | ||
)} | ||
|
||
{/* Instagram */} | ||
{isFieldToBeDisplayed('instagram') && ( | ||
<div className={styles.field}> | ||
<TextBox | ||
startIcon='logo-instagram' | ||
placeholder={getLabel('instagram')} | ||
value={props.values.instagram} | ||
onChange={onAnyFieldChange.bind( | ||
onAnyFieldChange, | ||
'instagram' | ||
)} | ||
errors={props.errors?.instagram} | ||
/> | ||
</div> | ||
)} | ||
</> | ||
)} | ||
</div> | ||
</div> | ||
); | ||
} |
Oops, something went wrong.