Skip to content

Commit 8b88de6

Browse files
authored
feat: add slot to extend the profile fields (#1211)
* feat: add extended profile fields functionality with context and form components * refactor: replace string literals with FORM_MODE constants in profile fields components * feat: implement BaseField component and refactor field elements to use it * chore: remove unused webpack development configuration file * feat: refactor extended profile fields implementation and remove unused components * feat: update dependencies for frontend-plugin-framework and remove unused dompurify * refactor: simplify pluginProps structure in ExtendedProfileFieldsSlot component * feat: add README and example images for Extended Profile Fields slot * refactor: improve performance & keep consistency * feat: add Additional Profile Fields slot with example implementation and documentation * feat: update custom fields image for Additional Profile Fields slot * fix: reorder import of AdditionalProfileFieldsSlot for consistency * test: fix snapshot * fix: adjust margin in example to avoid oddities on mobile * fix: remove unnecessary empty divs from ProfilePage snapshots
1 parent 872fa4c commit 8b88de6

File tree

7 files changed

+295
-0
lines changed

7 files changed

+295
-0
lines changed

package-lock.json

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@fortawesome/free-regular-svg-icons": "6.7.2",
4040
"@fortawesome/free-solid-svg-icons": "6.7.2",
4141
"@fortawesome/react-fontawesome": "0.2.3",
42+
"@openedx/frontend-plugin-framework": "^1.7.0",
4243
"@openedx/paragon": "^23.4.5",
4344
"@pact-foundation/pact": "^11.0.2",
4445
"@redux-devtools/extension": "3.3.0",
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 mt-5">
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;
62.6 KB
Loading
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { PluginSlot } from '@openedx/frontend-plugin-framework';
2+
import { useDispatch, useSelector } from 'react-redux';
3+
4+
import { useCallback } from 'react';
5+
import { patchProfile } from '../../profile/data/services';
6+
import { fetchProfile } from '../../profile/data/actions';
7+
8+
import SwitchContent from '../../profile/forms/elements/SwitchContent';
9+
import EmptyContent from '../../profile/forms/elements/EmptyContent';
10+
import EditableItemHeader from '../../profile/forms/elements/EditableItemHeader';
11+
12+
const AdditionalProfileFieldsSlot = () => {
13+
const dispatch = useDispatch();
14+
const extendedProfileValues = useSelector((state) => state.profilePage.account.extendedProfile);
15+
const errors = useSelector((state) => state.profilePage.errors);
16+
17+
const pluginProps = {
18+
refreshUserProfile: useCallback((username) => dispatch(fetchProfile(username)), [dispatch]),
19+
updateUserProfile: patchProfile,
20+
profileFieldValues: extendedProfileValues,
21+
profileFieldErrors: errors,
22+
formComponents: {
23+
SwitchContent,
24+
EmptyContent,
25+
EditableItemHeader,
26+
},
27+
};
28+
29+
return (
30+
<PluginSlot
31+
id="org.openedx.frontend.profile.additional_profile_fields.v1"
32+
pluginProps={pluginProps}
33+
/>
34+
);
35+
};
36+
37+
export default AdditionalProfileFieldsSlot;

src/profile/ProfilePage.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import messages from './ProfilePage.messages';
4242
import withParams from '../utils/hoc';
4343
import { useIsOnMobileScreen, useIsOnTabletScreen } from './data/hooks';
4444

45+
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
46+
4547
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL', 'ACCOUNT_SETTINGS_URL'], 'ProfilePage');
4648

4749
const ProfilePage = ({ params }) => {
@@ -347,6 +349,8 @@ const ProfilePage = ({ params }) => {
347349
{...commonFormProps}
348350
/>
349351
)}
352+
353+
<AdditionalProfileFieldsSlot />
350354
</div>
351355
<div
352356
className={classNames([
@@ -362,6 +366,7 @@ const ProfilePage = ({ params }) => {
362366
{...commonFormProps}
363367
/>
364368
)}
369+
365370
{isBlockVisible((socialLinks || []).some((link) => link?.socialLink !== null)) && (
366371
<SocialLinks
367372
socialLinks={socialLinks || []}

0 commit comments

Comments
 (0)