Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TASK-114] Split out AccountFieldsEditor from AccountSettingsRoute #4709

Merged
merged 19 commits into from
Nov 6, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0656ba2
create AccountFieldsEditor component
magicznyleszek Oct 26, 2023
1af4b98
Hook up AccountFieldsEditor to AccountSettingsRoute
magicznyleszek Oct 30, 2023
3b6803d
remove unneeded code from AccountSettingsRoute
magicznyleszek Oct 30, 2023
3c4fa35
simplify types a bit more and introduce getInitialAccountFieldsValues…
magicznyleszek Nov 1, 2023
6802776
Merge branch 'beta' into split-out-account-fields-editor
magicznyleszek Nov 1, 2023
e1e3cc3
user better types in envStore
magicznyleszek Nov 1, 2023
db45cff
post merge fixes
magicznyleszek Nov 1, 2023
fb47ecc
move out getProfilePatchData function out of AccountSettingsRoute
magicznyleszek Nov 1, 2023
5306805
introduce AccountFieldsErrors and make HACK less complex
magicznyleszek Nov 1, 2023
0aa3b21
Add comment
magicznyleszek Nov 2, 2023
cbe9780
improve types and move them around a bit
magicznyleszek Nov 2, 2023
2f7982d
make KoboSelect trigger red when it has an error
magicznyleszek Nov 2, 2023
7284888
Add label option to KoboSelect
magicznyleszek Nov 2, 2023
16f6724
change fields order and columnize the layout in AccountFieldsEditor
magicznyleszek Nov 2, 2023
028f519
make L TextBox size match L Button size (height)
magicznyleszek Nov 2, 2023
7c03ca5
run prettier on AccountFieldsEditor
magicznyleszek Nov 2, 2023
4a3e9bf
use standard naming convention for const
magicznyleszek Nov 3, 2023
a3507b3
added keyboard navigation to KoboDropdown and KoboSelect
magicznyleszek Nov 3, 2023
e0ef3e7
style fixes for having errors and for empty rows
magicznyleszek Nov 6, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 37 additions & 0 deletions jsapp/js/account/account.constants.ts
@@ -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
@@ -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
@@ -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>
);
}