Skip to content

Commit dd11ffd

Browse files
committed
feat: add Additional Profile Fields slot with example implementation and documentation
1 parent a622578 commit dd11ffd

File tree

7 files changed

+236
-56
lines changed

7 files changed

+236
-56
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Additional Profile Fields
2+
3+
### Slot ID: `org.openedx.frontend.profile.additional_profile_fields.v1`
4+
5+
## Description
6+
7+
This slot is used to replace/modify/hide the additional profile fields in the profile page.
8+
9+
## Example
10+
The following `env.config.jsx` will extend the default fields with a additional custom fields through a simple example component.
11+
12+
![Screenshot of Custom Fields](./images/custom_fields.png)
13+
14+
### Using the Additional Fields Component
15+
Create a file named `env.config.jsx` at the MFE root with this:
16+
17+
```jsx
18+
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
19+
import Example from './src/plugin-slots/AdditionalProfileFieldsSlot/example';
20+
21+
const config = {
22+
pluginSlots: {
23+
'org.openedx.frontend.profile.additional_profile_fields.v1': {
24+
plugins: [
25+
{
26+
op: PLUGIN_OPERATIONS.Insert,
27+
widget: {
28+
id: 'additional_profile_fields',
29+
type: DIRECT_PLUGIN,
30+
RenderWidget: Example,
31+
},
32+
},
33+
],
34+
},
35+
},
36+
};
37+
38+
export default config;
39+
```
40+
41+
## Plugin Props
42+
43+
When implementing a plugin for this slot, the following props are available:
44+
45+
### `updateUserProfile`
46+
- **Type**: Function
47+
- **Description**: A function for updating the user's profile with new field values. This handles the API call to persist changes to the backend.
48+
- **Usage**: Pass an object containing the field updates to be saved to the user's profile. The function automatically handles the persistence and UI updates.
49+
50+
#### Example
51+
```javascript
52+
updateUserProfile({ extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
53+
```
54+
55+
### `profileFieldValues`
56+
- **Type**: Array of Objects
57+
- **Description**: Contains the current values of all additional profile fields as an array of objects. Each object has a `fieldName` property (string) and a `fieldValue` property (which can be string, boolean, number, or other data types depending on the field type).
58+
- **Usage**: Access specific field values by finding the object with the matching `fieldName` and reading its `fieldValue` property. Use array methods like `find()` to locate specific fields.
59+
60+
#### Example
61+
```javascript
62+
// Finding a specific field value
63+
const nifField = profileFieldValues.find(field => field.fieldName === 'nif');
64+
const nifValue = nifField ? nifField.fieldValue : null;
65+
66+
// Example data structure:
67+
[
68+
{
69+
"fieldName": "favorite_color",
70+
"fieldValue": "red"
71+
},
72+
{
73+
"fieldName": "employment_situation",
74+
"fieldValue": "Unemployed"
75+
},
76+
]
77+
```
78+
79+
### `profileFieldErrors`
80+
- **Type**: Object
81+
- **Description**: Contains validation errors for profile fields. Each key corresponds to a field name, and the value is the error message.
82+
- **Usage**: Check for field-specific errors to display validation feedback to users.
83+
84+
### `formComponents`
85+
- **Type**: Object
86+
- **Description**: Provides access to reusable form components that are consistent with the rest of the profile page styling and behavior. These components follow the platform's design system and include proper validation and accessibility features.
87+
- **Usage**: Use these components in your custom fields implementation to maintain UI consistency. Available components include `SwitchContent` for managing different UI states, `EmptyContent` for empty states, and `EditableItemHeader` for consistent headers.
88+
89+
### `refreshUserProfile`
90+
- **Type**: Function
91+
- **Description**: A function that triggers a refresh of the user's profile data. This can be used after updating profile fields to ensure the UI reflects the latest data from the server.
92+
- **Usage**: Call this function with the username parameter when you need to manually reload the user profile information. Note that `updateUserProfile` typically handles data refresh automatically.
93+
94+
#### Example
95+
```javascript
96+
refreshUserProfile(username);
97+
```
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useEffect, useState } from 'react';
2+
import PropTypes from 'prop-types';
3+
import { Button } from '@openedx/paragon';
4+
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
5+
6+
/**
7+
* Straightforward example of how you could use the pluginProps provided by
8+
* the AdditionalProfileFieldsSlot to create a custom profile field.
9+
*
10+
* Here you can set a 'favorite_color' field with radio buttons and
11+
* save it to the user's profile, especifically to their `meta` in
12+
* the user's model. For more information, see the documentation:
13+
*
14+
* https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/user_api/README.rst#persisting-optional-user-metadata
15+
*/
16+
const Example = ({
17+
updateUserProfile,
18+
profileFieldValues,
19+
profileFieldErrors,
20+
formComponents: { SwitchContent, EditableItemHeader, EmptyContent } = {},
21+
}) => {
22+
const authenticatedUser = getAuthenticatedUser();
23+
const [formMode, setFormMode] = useState('editable');
24+
25+
// Get current favorite color from profileFieldValues
26+
const currentColorField = profileFieldValues?.find(field => field.fieldName === 'favorite_color');
27+
const currentColor = currentColorField ? currentColorField.fieldValue : '';
28+
29+
const [value, setValue] = useState(currentColor);
30+
const handleChange = e => setValue(e.target.value);
31+
32+
// Get any validation errors for the favorite_color field
33+
const colorFieldError = profileFieldErrors?.favorite_color;
34+
35+
useEffect(() => {
36+
if (!value) { setFormMode('empty'); }
37+
if (colorFieldError) {
38+
setFormMode('editing');
39+
}
40+
}, [colorFieldError, value]);
41+
42+
const handleSubmit = () => {
43+
try {
44+
updateUserProfile(authenticatedUser.username, { extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
45+
setFormMode('editable');
46+
} catch (error) {
47+
setFormMode('editing');
48+
}
49+
};
50+
51+
return (
52+
<div className="border .border-accent-500 p-3">
53+
<h3 className="h3">Example Additional Profile Fields Slot</h3>
54+
55+
<SwitchContent
56+
className="pt-40px"
57+
expression={formMode}
58+
cases={{
59+
editing: (
60+
<>
61+
<label className="edit-section-header" htmlFor="favorite_color">
62+
Favorite Color
63+
</label>
64+
<input
65+
className="form-control"
66+
id="favorite_color"
67+
name="favorite_color"
68+
value={value}
69+
onChange={handleChange}
70+
/>
71+
<Button type="button" className="mt-2" onClick={handleSubmit}>
72+
Save
73+
</Button>
74+
</>
75+
),
76+
editable: (
77+
<>
78+
<div className="row m-0 pb-1.5 align-items-center">
79+
<p data-hj-suppress className="h5 font-weight-bold m-0">
80+
Favorite Color
81+
</p>
82+
</div>
83+
<EditableItemHeader
84+
content={value}
85+
showEditButton
86+
onClickEdit={() => setFormMode('editing')}
87+
showVisibility={false}
88+
visibility="private"
89+
/>
90+
</>
91+
),
92+
empty: (
93+
<>
94+
<div className="row m-0 pb-1.5 align-items-center">
95+
<p data-hj-suppress className="h5 font-weight-bold m-0">
96+
Favorite Color
97+
</p>
98+
</div>
99+
<EmptyContent onClick={() => setFormMode('editing')}>
100+
<p className="mb-0">Click to add your favorite color</p>
101+
</EmptyContent>
102+
</>
103+
),
104+
}}
105+
/>
106+
107+
</div>
108+
);
109+
};
110+
111+
Example.propTypes = {
112+
updateUserProfile: PropTypes.func.isRequired,
113+
profileFieldValues: PropTypes.arrayOf(
114+
PropTypes.shape({
115+
fieldName: PropTypes.string.isRequired,
116+
fieldValue: PropTypes.oneOfType([
117+
PropTypes.string,
118+
PropTypes.bool,
119+
PropTypes.number,
120+
]).isRequired,
121+
}),
122+
),
123+
profileFieldErrors: PropTypes.objectOf(PropTypes.string),
124+
formComponents: PropTypes.shape({
125+
SwitchContent: PropTypes.elementType.isRequired,
126+
}),
127+
};
128+
129+
export default Example;

src/plugin-slots/ExtendedProfileFieldsSlot/index.jsx renamed to src/plugin-slots/AdditionalProfileFieldsSlot/index.jsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@ import SwitchContent from '../../profile/forms/elements/SwitchContent';
99
import EmptyContent from '../../profile/forms/elements/EmptyContent';
1010
import EditableItemHeader from '../../profile/forms/elements/EditableItemHeader';
1111

12-
const ExtendedProfileFieldsSlot = () => {
12+
const AdditionalProfileFieldsSlot = () => {
1313
const dispatch = useDispatch();
1414
const extendedProfileValues = useSelector((state) => state.profilePage.account.extendedProfile);
15+
const errors = useSelector((state) => state.profilePage.errors);
1516

1617
const pluginProps = {
1718
refreshUserProfile: useCallback((username) => dispatch(fetchProfile(username)), [dispatch]),
1819
updateUserProfile: patchProfile,
1920
profileFieldValues: extendedProfileValues,
21+
profileFieldErrors: errors,
2022
formComponents: {
2123
SwitchContent,
2224
EmptyContent,
@@ -26,11 +28,10 @@ const ExtendedProfileFieldsSlot = () => {
2628

2729
return (
2830
<PluginSlot
29-
id="org.openedx.frontend.profile.extended_profile_fields.v1"
30-
idAliases={['extended_profile_fields_slot']}
31+
id="org.openedx.frontend.profile.additional_profile_fields.v1"
3132
pluginProps={pluginProps}
3233
/>
3334
);
3435
};
3536

36-
export default ExtendedProfileFieldsSlot;
37+
export default AdditionalProfileFieldsSlot;

src/plugin-slots/ExtendedProfileFieldsSlot/README.md

Lines changed: 0 additions & 49 deletions
This file was deleted.
Binary file not shown.

src/profile/ProfilePage.jsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
import { InfoOutline } from '@openedx/paragon/icons';
1616
import classNames from 'classnames';
1717

18-
import ExtendedProfileFieldsSlot from '../plugin-slots/ExtendedProfileFieldsSlot';
18+
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
1919

2020
// Actions
2121
import {
@@ -350,6 +350,10 @@ const ProfilePage = ({ params }) => {
350350
{...commonFormProps}
351351
/>
352352
)}
353+
354+
<div className="pt-40px">
355+
<AdditionalProfileFieldsSlot />
356+
</div>
353357
</div>
354358
<div
355359
className={classNames([
@@ -366,8 +370,6 @@ const ProfilePage = ({ params }) => {
366370
/>
367371
)}
368372

369-
<ExtendedProfileFieldsSlot />
370-
371373
{isBlockVisible((socialLinks || []).some((link) => link?.socialLink !== null)) && (
372374
<SocialLinks
373375
socialLinks={socialLinks || []}

0 commit comments

Comments
 (0)