Compare commits

..

2 Commits

Author SHA1 Message Date
Awais Ansari
9c78bf34ef feat: added env parse to boolean functionality (#1390) 2025-12-01 19:51:35 +05:00
Awais Ansari
36827914eb fix: removed hard-coded waffle flag checks for email and push notifications (#1387) 2025-11-24 17:10:42 +05:00
23 changed files with 6641 additions and 3066 deletions

View File

@@ -20,5 +20,5 @@ Include a link to the sandbox for design changes or screenshot for before and af
#### Post-merge Checklist
* [ ] Deploy the changes to prod after verifying on stage or ask **@jacobo-dominguez-wgu** to do it.
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.

View File

@@ -89,9 +89,9 @@ Cloning and Startup
``git clone https://github.com/openedx/frontend-app-account.git``
2. Use the version of Node specified in the ``.nvmrc`` file.
2. Use node v18.x.
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.

View File

@@ -14,6 +14,6 @@ metadata:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: jacobo-dominguez-wgu
owner: group:2u-infinity
type: 'website'
lifecycle: 'production'

9568
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,9 +44,9 @@
"@tensorflow-models/blazeface": "0.1.0",
"@tensorflow/tfjs-converter": "4.22.0",
"@tensorflow/tfjs-core": "4.22.0",
"bowser": "2.14.1",
"bowser": "2.12.1",
"classnames": "2.5.1",
"core-js": "3.48.0",
"core-js": "3.46.0",
"font-awesome": "4.7.0",
"form-urlencoded": "6.1.6",
"formdata-polyfill": "4.0.10",
@@ -64,7 +64,7 @@
"long": "5.3.2",
"memoize-one": "^6.0.0",
"prop-types": "15.8.1",
"qs": "6.15.0",
"qs": "6.14.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-helmet": "6.1.0",
@@ -77,14 +77,14 @@
"redux": "4.2.1",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-saga": "1.4.2",
"redux-saga": "1.3.0",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"reselect": "^5.1.1",
"universal-cookie": "7.2.2"
},
"devDependencies": {
"@edx/browserslist-config": "1.5.1",
"@edx/browserslist-config": "1.5.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "14.3.1",

View File

@@ -764,11 +764,11 @@ class AccountSettingsPage extends React.Component {
{...editableFieldProps}
/>
<EditableField
name="social_link_x"
name="social_link_twitter"
type="text"
value={this.props.formValues.social_link_x}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.xTwitter'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.xTwitter.empty'])}
value={this.props.formValues.social_link_twitter}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter.empty'])}
{...editableFieldProps}
/>
</div>
@@ -905,7 +905,7 @@ AccountSettingsPage.propTypes = {
phone_number: PropTypes.string,
social_link_linkedin: PropTypes.string,
social_link_facebook: PropTypes.string,
social_link_x: PropTypes.string,
social_link_twitter: PropTypes.string,
time_zone: PropTypes.string,
state: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool.isRequired,

View File

@@ -509,15 +509,15 @@ const messages = defineMessages({
defaultMessage: 'Delete My Account',
description: 'Header for the user account deletion area',
},
'account.settings.field.social.platform.name.xTwitter': {
id: 'account.settings.field.social.platform.name.xTwitter',
defaultMessage: 'X (Twitter)',
description: 'Label for X (Twitter)',
'account.settings.field.social.platform.name.twitter': {
id: 'account.settings.field.social.platform.name.twitter',
defaultMessage: 'Twitter',
description: 'Label for Twitter',
},
'account.settings.field.social.platform.name.xTwitter.empty': {
id: 'account.settings.field.social.platform.name.xTwitter.empty',
defaultMessage: 'Add X profile',
description: 'Placeholder for an empty X field',
'account.settings.field.social.platform.name.twitter.empty': {
id: 'account.settings.field.social.platform.name.twitter.empty',
defaultMessage: 'Add Twitter profile',
description: 'Placeholder for an empty Twitter field',
},
'account.settings.field.social.platform.name.facebook': {

View File

@@ -11,7 +11,7 @@ import { postVerifiedNameConfig } from '../certificate-preference/data/service';
import { FIELD_LABELS } from './constants';
const SOCIAL_PLATFORMS = [
{ id: 'xTwitter', key: 'social_link_x' },
{ id: 'twitter', key: 'social_link_twitter' },
{ id: 'facebook', key: 'social_link_facebook' },
{ id: 'linkedin', key: 'social_link_linkedin' },
];

View File

@@ -38,24 +38,24 @@ describe('account service', () => {
it('returns unpacked account data', async () => {
const apiResponse = {
username: 'testuser',
social_links: [{ platform: 'xTwitter', social_link: 'http://t' }],
social_links: [{ platform: 'twitter', social_link: 'http://t' }],
language_proficiencies: [{ code: 'en' }],
};
mockHttpClient.get.mockResolvedValue({ data: apiResponse });
const result = await getAccount('testuser');
expect(mockHttpClient.get).toHaveBeenCalledWith('http://lms.test/api/user/v1/accounts/testuser');
expect(result.social_link_x).toEqual('http://t');
expect(result.social_link_twitter).toEqual('http://t');
expect(result.language_proficiencies).toEqual('en');
});
});
describe('patchAccount', () => {
it('sends packed commit data and returns unpacked response', async () => {
const commit = { social_link_x: 'http://t' };
const commit = { social_link_twitter: 'http://t' };
const apiResponse = {
username: 'testuser',
social_links: [{ platform: 'xTwitter', social_link: 'http://t' }],
social_links: [{ platform: 'twitter', social_link: 'http://t' }],
language_proficiencies: [],
};
mockHttpClient.patch.mockResolvedValue({ data: apiResponse });
@@ -63,10 +63,10 @@ describe('account service', () => {
const result = await patchAccount('testuser', commit);
expect(mockHttpClient.patch).toHaveBeenCalledWith(
'http://lms.test/api/user/v1/accounts/testuser',
expect.objectContaining({ social_links: [{ platform: 'xTwitter', social_link: 'http://t' }] }),
expect.objectContaining({ social_links: [{ platform: 'twitter', social_link: 'http://t' }] }),
expect.any(Object),
);
expect(result.social_link_x).toEqual('http://t');
expect(result.social_link_twitter).toEqual('http://t');
});
});

View File

@@ -81,8 +81,8 @@ export class ConfirmationModal extends Component {
isOverflowVisible
footerNode={(
<ActionRow>
<Button variant="link" onClick={onCancel}>{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.cancel'])}</Button>
<Button variant="danger" onClick={onSubmit}>{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.delete'])}</Button>
<Button variant="link" onClick={onCancel}>Cancel</Button>
<Button variant="danger" onClick={onSubmit}>Yes, Delete</Button>
</ActionRow>
)}
>

View File

@@ -38,7 +38,6 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
role="presentation"
/>
<div
aria-label="Are you sure?"
@@ -248,17 +247,15 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
"width": "1px",
}
}
tabIndex={0}
tabIndex={-1}
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled={false}
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
@@ -269,7 +266,6 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
role="presentation"
/>
<div
aria-label="Are you sure?"
@@ -397,7 +393,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
"width": "1px",
}
}
tabIndex={0}
tabIndex={-1}
/>,
]
`;

View File

@@ -23,17 +23,15 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
"width": "1px",
}
}
tabIndex={0}
tabIndex={-1}
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled={false}
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
@@ -44,7 +42,6 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
role="presentation"
/>
<div
className="mw-sm p-5 bg-white mx-auto my-3"
@@ -87,7 +84,7 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
"width": "1px",
}
}
tabIndex={0}
tabIndex={-1}
/>,
]
`;

View File

@@ -11,7 +11,6 @@ import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import AccountSettingsPage from '../AccountSettingsPage';
import mockData from './mockData';
import messages from '../AccountSettingsPage.messages';
const mockDispatch = jest.fn();
jest.mock('@edx/frontend-platform/analytics', () => ({
@@ -190,7 +189,7 @@ describe('AccountSettingsPage', () => {
expect(screen.getByText('https://linkedin.com/in/testuser')).toBeInTheDocument();
expect(screen.getByText('Add Facebook profile')).toBeInTheDocument();
expect(screen.getByText(messages['account.settings.field.social.platform.name.xTwitter.empty'].defaultMessage)).toBeInTheDocument();
expect(screen.getByText('Add Twitter profile')).toBeInTheDocument();
});
it('renders Site Preferences section with correct field values', () => {

View File

@@ -71,5 +71,5 @@ export function useFeedbackWrapper() {
export function useIsOnMobile() {
const windowSize = useWindowSize();
return windowSize.width <= breakpoints.small.maxWidth;
return windowSize.width <= breakpoints.small.minWidth;
}

View File

@@ -18,7 +18,9 @@ import {
selectUpdatePreferencesStatus,
} from './data/selectors';
import { notificationChannels, shouldHideAppPreferences } from './data/utils';
import { EMAIL, EMAIL_CADENCE } from './data/constants';
import {
EMAIL, EMAIL_CADENCE, EMAIL_CADENCE_PREFERENCES, MIXED,
} from './data/constants';
const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
const dispatch = useDispatch();
@@ -37,11 +39,13 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
return checked;
}, []);
const getEmailCadence = useCallback((notificationChannel, innerText, emailCadence) => {
const getEmailCadence = useCallback((notificationChannel, checked, innerText, emailCadence) => {
if (notificationChannel === EMAIL_CADENCE) {
return innerText;
}
if (notificationChannel === EMAIL && checked) {
return EMAIL_CADENCE_PREFERENCES.DAILY;
}
return emailCadence;
}, []);
@@ -52,6 +56,7 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
const value = getValue(notificationChannel, innerText, checked);
const emailCadence = getEmailCadence(
notificationChannel,
checked,
innerText,
appNotificationPreference.emailCadence,
);
@@ -61,11 +66,12 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
notificationType,
notificationChannel,
value,
emailCadence,
emailCadence !== MIXED ? emailCadence : undefined,
));
}, [appPreferences, getValue, getEmailCadence, dispatch, appId]);
const renderPreference = (preference) => (
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
<div
key={`${preference.id}-${channel}`}
id={`${preference.id}-${channel}`}
@@ -94,6 +100,7 @@ const NotificationPreferenceColumn = ({ appId, channel, appPreference }) => {
/>
)}
</div>
)
);
return (

View File

@@ -249,7 +249,7 @@ describe('Notification Preferences API v2 Logic', () => {
describe('getNotificationPreferences', () => {
it('should call the v2 configurations URL', async () => {
const expectedUrl = `${LMS_BASE_URL}/api/notifications/v3/configurations/`;
const expectedUrl = `${LMS_BASE_URL}/api/notifications/v2/configurations/`;
await getNotificationPreferences();
@@ -260,7 +260,7 @@ describe('Notification Preferences API v2 Logic', () => {
describe('postPreferenceToggle', () => {
it('should call the v2 configurations URL with PUT method', async () => {
const expectedUrl = `${LMS_BASE_URL}/api/notifications/v3/configurations/`;
const expectedUrl = `${LMS_BASE_URL}/api/notifications/v2/configurations/`;
const testArgs = ['app_name', 'notification_type', 'web', true, 'daily'];
await postPreferenceToggle(...testArgs);

View File

@@ -23,6 +23,7 @@ const NotificationTypes = ({ appId }) => {
return (
<div className="d-flex flex-column mr-auto px-0">
{preferences.map(preference => (
(preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && (
<>
<div
key={preference.id}
@@ -55,6 +56,8 @@ const NotificationTypes = ({ appId }) => {
</div>
)}
</>
)
))}
</div>
);

View File

@@ -5,6 +5,7 @@ export const EMAIL_CADENCE_PREFERENCES = {
};
export const EMAIL_CADENCE = 'email_cadence';
export const EMAIL = 'email';
export const MIXED = 'Mixed';
export const RequestStatus = /** @type {const} */ ({
IN_PROGRESS: 'in-progress',
SUCCESSFUL: 'successful',

View File

@@ -3,7 +3,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import snakeCase from 'lodash.snakecase';
export const getNotificationPreferences = async () => {
const url = `${getConfig().LMS_BASE_URL}/api/notifications/v3/configurations/`;
const url = `${getConfig().LMS_BASE_URL}/api/notifications/v2/configurations/`;
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
};
@@ -16,13 +16,13 @@ export const postPreferenceToggle = async (
emailCadence,
) => {
const patchData = snakeCaseObject({
notificationApp: snakeCase(notificationApp),
notificationApp,
notificationType: snakeCase(notificationType),
notificationChannel,
value,
emailCadence,
});
const url = `${getConfig().LMS_BASE_URL}/api/notifications/v3/configurations/`;
const url = `${getConfig().LMS_BASE_URL}/api/notifications/v2/configurations/`;
const { data } = await getAuthenticatedHttpClient().put(url, patchData);
return data;
};

View File

@@ -38,7 +38,7 @@ describe('Notification Preferences Service', () => {
const result = await getNotificationPreferences();
expect(mockHttpClient.get).toHaveBeenCalledWith(
'http://test.lms/api/notifications/v3/configurations/',
'http://test.lms/api/notifications/v2/configurations/',
);
expect(result).toEqual(mockData);
});
@@ -50,7 +50,7 @@ describe('Notification Preferences Service', () => {
mockHttpClient.put.mockResolvedValue({ data: mockData });
const result = await postPreferenceToggle(
'app_name',
'appName',
'someType',
'email',
true,
@@ -58,9 +58,9 @@ describe('Notification Preferences Service', () => {
);
expect(mockHttpClient.put).toHaveBeenCalledWith(
'http://test.lms/api/notifications/v3/configurations/',
'http://test.lms/api/notifications/v2/configurations/',
expect.objectContaining({
notification_app: 'app_name',
notification_app: 'appName',
notification_type: 'some_type',
notification_channel: 'email',
value: true,

View File

@@ -35,7 +35,7 @@ const normalizePreferences = (responseData) => {
const apps = appKeys.map((appId) => ({
id: appId,
enabled: preferences[appId].enabled,
})).sort((a, b) => a.id.localeCompare(b.id));
}));
const nonEditable = {};
const preferenceList = appKeys.map(appId => {
@@ -50,6 +50,7 @@ const normalizePreferences = (responseData) => {
info: preferences[appId].notificationTypes[preferenceId].info || '',
emailCadence: preferences[appId].notificationTypes[preferenceId].emailCadence
|| EMAIL_CADENCE_PREFERENCES.DAILY,
coreNotificationTypes: preferences[appId].coreNotificationTypes || [],
}
));
nonEditable[appId] = preferences[appId].nonEditable;
@@ -121,7 +122,7 @@ export const updatePreferenceToggle = (
const emailCadenceData = await togglePreference(
EMAIL_CADENCE,
value,
emailCadence,
EMAIL_CADENCE_PREFERENCES.DAILY,
);
handleSuccessResponse(emailCadenceData);

View File

@@ -10,5 +10,12 @@ export const notificationChannels = () => ({
export const shouldHideAppPreferences = (preferences, appId) => {
const appPreferences = preferences.filter(pref => pref.appId === appId);
return appPreferences.length === 0;
if (appPreferences.length !== 1) {
return false;
}
const firstPreference = appPreferences[0];
return firstPreference?.id === 'core' && (!firstPreference.coreNotificationTypes?.length);
};

View File

@@ -22,7 +22,7 @@ const messages = defineMessages({
id: 'notification.preference.title',
defaultMessage: `{
text, select,
groupedNotification {Activity notifications}
core {Activity notifications}
newDiscussionPost {New discussion posts}
newQuestionPost {New question posts}
contentReported {Reported content}