Skip to content

Commit

Permalink
Merge pull request #4709 from kobotoolbox/split-out-account-fields-ed…
Browse files Browse the repository at this point in the history
…itor

[TASK-114] Split out AccountFieldsEditor from AccountSettingsRoute
  • Loading branch information
magicznyleszek committed Nov 6, 2023
2 parents 0d17a87 + e0ef3e7 commit 6ac0d66
Show file tree
Hide file tree
Showing 12 changed files with 570 additions and 334 deletions.
37 changes: 37 additions & 0 deletions jsapp/js/account/account.constants.ts
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',
};
63 changes: 63 additions & 0 deletions jsapp/js/account/account.utils.ts
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;
}
292 changes: 292 additions & 0 deletions jsapp/js/account/accountFieldsEditor.component.tsx
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>
);
}

0 comments on commit 6ac0d66

Please sign in to comment.