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

feat: Conditionally render expiring learner credit alerts and modals #1224

Merged
merged 7 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/components/Admin/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,6 @@ class Admin extends React.Component {
insights,
insightsLoading,
} = this.props;

const queryParams = new URLSearchParams(search || '');
const queryParamsLength = Array.from(queryParams.entries()).length;
const filtersActive = queryParamsLength !== 0 && !(queryParamsLength === 1 && queryParams.has('ordering'));
Expand Down
22 changes: 12 additions & 10 deletions src/components/BudgetExpiryAlertAndModal/data/hooks/useExpiry.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import {
getEnterpriseBudgetExpiringAlertCookieName,
getEnterpriseBudgetExpiringModalCookieName,
getExpirationMetadata,
getNonExpiredBudgets,
getExpiredAndNonExpiredBudgets,
} from '../utils';

const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, alertClose) => {
const [notification, setNotification] = useState(null);
const [expirationThreshold, setExpirationThreshold] = useState(null);
const [modal, setModal] = useState(null);
const [isNonExpiredBudget, setIsNonExpiredBudget] = useState(false);

useEffect(() => {
if (!budgets || budgets.length === 0) {
Expand All @@ -20,18 +21,19 @@ const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, aler
// expired and non-expired budgets. In that case, we only want
// to determine the expiry threshold from the set of *non-expired* budgets,
// so that the alert and modal below do not falsely signal.
let budgetsToConsiderForExpirationMessaging = budgets;
let budgetsToConsiderForExpirationMessaging = [];

const nonExpiredBudgets = getNonExpiredBudgets(budgets);
const hasNonExpiredBudgets = nonExpiredBudgets.length > 0;
const { nonExpiredBudgets, expiredBudgets } = getExpiredAndNonExpiredBudgets(budgets);

// If the length of all budgets is different from the length of non-expired budgets,
// then there exists at least one expired budget (note that we already early-returned
// above if there are zero total budgets).
const hasExpiredBudgets = budgets.length !== nonExpiredBudgets.length;
// Consider the length of each budget
const hasNonExpiredBudgets = nonExpiredBudgets.length > 0;

if (hasNonExpiredBudgets && hasExpiredBudgets) {
// If an unexpired budget exists, set budgetsToConsiderForExpirationMessaging to nonExpiredBudgets
if (hasNonExpiredBudgets) {
budgetsToConsiderForExpirationMessaging = nonExpiredBudgets;
setIsNonExpiredBudget(true);
} else {
budgetsToConsiderForExpirationMessaging = expiredBudgets;
}

const earliestExpiryBudget = budgetsToConsiderForExpirationMessaging.reduce(
Expand Down Expand Up @@ -97,7 +99,7 @@ const useExpiry = (enterpriseId, budgets, modalOpen, modalClose, alertOpen, aler
};

return {
notification, modal, dismissModal, dismissAlert,
notification, modal, dismissModal, dismissAlert, isNonExpiredBudget,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,54 +12,74 @@ const modalClose = jest.fn();
const alertOpen = jest.fn();
const alertClose = jest.fn();

const offsetDays = {
120: dayjs().add(120, 'day'),
90: dayjs().add(90, 'day'),
60: dayjs().add(60, 'day'),
30: dayjs().add(30, 'day'),
10: dayjs().add(10, 'day'),
1: dayjs().subtract(1, 'day'),
};

describe('useExpiry', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it.each([
(() => {
const endDate = dayjs().add(120, 'day');
return { endDate, expected: expiryThresholds[120]({ date: formatDate(endDate.toString()) }) };
})(),
(() => {
const endDate = dayjs().add(90, 'day');
return { endDate, expected: expiryThresholds[90]({ date: formatDate(endDate.toString()) }) };
})(),
(() => {
const endDate = dayjs().add(60, 'day');
return { endDate, expected: expiryThresholds[60]({ date: formatDate(endDate.toString()) }) };
})(),
(() => {
const endDate = dayjs().add(30, 'day');
return { endDate, expected: expiryThresholds[30]({ date: formatDate(endDate.toString()) }) };
})(),
(() => {
const endDate = dayjs().add(10, 'day');
const today = dayjs().add(1, 'minutes');
const durationDiff = dayjs.duration(endDate.diff(today));

return {
endDate,
expected: expiryThresholds[10]({
date: formatDate(endDate.toString()),
days: durationDiff.days(),
hours: durationDiff.hours(),
}),
};
})(),
(() => {
const endDate = dayjs().subtract(1, 'day');
return { endDate, expected: expiryThresholds[0]({ date: formatDate(endDate.toString()) }) };
})(),
])('displays correct notification and modal when plan is expiring in %s days', ({ endDate, expected }) => {
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[kudos] Nice clean up on the it.each syntax :)

endDate: offsetDays['120'],
expected: expiryThresholds[120]({
date: formatDate(offsetDays['120'].toString()),
}),
isNonExpiredBudget: true,
},
{
endDate: offsetDays['90'],
expected: expiryThresholds[90]({
date: formatDate(offsetDays['90'].toString()),
}),
isNonExpiredBudget: true,
},
{
endDate: offsetDays['60'],
expected: expiryThresholds[60]({
date: formatDate(offsetDays['60'].toString()),
}),
isNonExpiredBudget: true,
},
{
endDate: offsetDays['30'],
expected: expiryThresholds[30]({
date: formatDate(offsetDays['30'].toString()),
}),
isNonExpiredBudget: true,
},
{
endDate: offsetDays['10'],
expected: expiryThresholds[10]({
date: formatDate(offsetDays['10'].toString()),
days: dayjs.duration(offsetDays['10'].diff(dayjs())).days(),
hours: dayjs.duration(offsetDays['10'].diff(dayjs())).hours(),
}),
isNonExpiredBudget: true,
},
{
endDate: offsetDays['1'],
expected: expiryThresholds[0]({
date: formatDate(offsetDays['1'].toString()),
}),
isNonExpiredBudget: false,
},
])('displays correct notification and modal when plan is expiring in %s days', ({ endDate, expected, isNonExpiredBudget }) => {
const budgets = [{ end: endDate }]; // Mock data with an expiring budget

const { result } = renderHook(() => useExpiry('enterpriseId', budgets, modalOpen, modalClose, alertOpen, alertClose));

expect(result.current.notification).toEqual(expected.notificationTemplate);
expect(result.current.modal).toEqual(expected.modalTemplate);
expect(result.current.status).toEqual(expected.status);
expect(result.current.isNonExpiredBudget).toEqual(isNonExpiredBudget);
});

it('displays no notification with both an expired and non-expired budget', () => {
Expand Down
90 changes: 90 additions & 0 deletions src/components/BudgetExpiryAlertAndModal/data/index.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClientProvider } from '@tanstack/react-query';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { renderWithRouter } from '@edx/frontend-enterprise-utils';
import { v4 as uuidv4 } from 'uuid';
import dayjs from 'dayjs';
import BudgetExpiryAlertAndModal from '../index';
import { queryClient } from '../../test/testUtils';
import { useEnterpriseBudgets } from '../../EnterpriseSubsidiesContext/data/hooks';

jest.mock('../../EnterpriseSubsidiesContext/data/hooks', () => ({
...jest.requireActual('../../EnterpriseSubsidiesContext/data/hooks'),
useEnterpriseBudgets: jest.fn(),
}));

const mockStore = configureMockStore([thunk]);
const getMockStore = store => mockStore(store);
const enterpriseSlug = 'test-enterprise';
const enterpriseUUID = '1234';
const initialStoreState = {
portalConfiguration: {
enterpriseId: enterpriseUUID,
enterpriseSlug,
disableExpiryMessagingForLearnerCredit: false,
enterpriseFeatures: {
topDownAssignmentRealTimeLcm: true,
},
},
};
const mockEnterpriseBudgetUuid = uuidv4();
const mockEnterpriseBudget = [
{
source: 'policy',
id: mockEnterpriseBudgetUuid,
name: 'test expiration plan 2 --- Everything',
start: '2024-04-15T00:00:00Z',
end: dayjs().add(11, 'days'),
isCurrent: true,
aggregates: {
available: 20000,
spent: 0,
pending: 0,
},
isAssignable: true,
isRetired: false,
},
];

const mockEndDateText = mockEnterpriseBudget[0].end.format('MMM D, YYYY');

const BudgetExpiryAlertAndModalWrapper = ({
initialState = initialStoreState,
}) => {
const store = getMockStore(initialState);
return (
<QueryClientProvider client={queryClient()}>
<IntlProvider locale="en">
<Provider store={store}>
<BudgetExpiryAlertAndModal />
</Provider>
</IntlProvider>
</QueryClientProvider>
);
};

describe('BudgetExpiryAlertAndModal', () => {
beforeEach(() => {
jest.clearAllMocks();
useEnterpriseBudgets.mockReturnValue({ data: mockEnterpriseBudget });
});
it('renders without crashing', () => {
renderWithRouter(<BudgetExpiryAlertAndModalWrapper />);
expect(screen.getByTestId('expiry-notification-alert')).toBeTruthy();
expect(screen.getByText(`Your Learner Credit plan expires ${mockEndDateText}.`, { exact: false })).toBeTruthy();
});
it('does not render when budget is non expired and disableExpiryMessagingForLearnerCredit is true', () => {
const updatedInitialStoreState = {
portalConfiguration: {
...initialStoreState.portalConfiguration,
disableExpiryMessagingForLearnerCredit: true,
},
};
renderWithRouter(<BudgetExpiryAlertAndModalWrapper initialState={updatedInitialStoreState} />);
expect(screen.queryByTestId('expiry-notification-alert')).toBeFalsy();
expect(screen.queryByText(`Your Learner Credit plan expires ${mockEndDateText}.`, { exact: false })).toBeFalsy();
});
});
16 changes: 14 additions & 2 deletions src/components/BudgetExpiryAlertAndModal/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@ import ExpiryThresholds from './expiryThresholds';

dayjs.extend(duration);

export const getNonExpiredBudgets = (budgets) => {
export const getExpiredAndNonExpiredBudgets = (budgets) => {
const today = dayjs();
return budgets.filter((budget) => today <= dayjs(budget.end));
const nonExpiredBudgets = [];
const expiredBudgets = [];
budgets.forEach((budget) => {
if (today <= dayjs(budget.end)) {
nonExpiredBudgets.push(budget);
} else {
expiredBudgets.push(budget);
}
});
return {
nonExpiredBudgets,
expiredBudgets,
};
};

export const getExpirationMetadata = (endDateStr) => {
Expand Down
11 changes: 8 additions & 3 deletions src/components/BudgetExpiryAlertAndModal/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ import EVENT_NAMES from '../../eventTracking';

import useExpiry from './data/hooks/useExpiry';

const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures, disableExpiryMessagingForLearnerCredit }) => {
const [modalIsOpen, modalOpen, modalClose] = useToggle(false);
const [alertIsOpen, alertOpen, alertClose] = useToggle(false);

const location = useLocation();

const budgetDetailRouteMatch = matchPath(
Expand All @@ -46,7 +45,7 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
});

const {
notification, modal, dismissModal, dismissAlert,
notification, modal, dismissModal, dismissAlert, isNonExpiredBudget,
} = useExpiry(
enterpriseUUID,
budgets,
Expand All @@ -64,6 +63,10 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
};
}, [modal, notification]);

if (isNonExpiredBudget && disableExpiryMessagingForLearnerCredit) {
return null;
}

return (
<>
{notification && (
Expand Down Expand Up @@ -141,13 +144,15 @@ const BudgetExpiryAlertAndModal = ({ enterpriseUUID, enterpriseFeatures }) => {
const mapStateToProps = state => ({
enterpriseUUID: state.portalConfiguration.enterpriseId,
enterpriseFeatures: state.portalConfiguration.enterpriseFeatures,
disableExpiryMessagingForLearnerCredit: state.portalConfiguration.disableExpiryMessagingForLearnerCredit,
});

BudgetExpiryAlertAndModal.propTypes = {
enterpriseUUID: PropTypes.string.isRequired,
enterpriseFeatures: PropTypes.shape({
topDownAssignmentRealTimeLcm: PropTypes.bool.isRequired,
}),
disableExpiryMessagingForLearnerCredit: PropTypes.bool.isRequired,
};

export default connect(mapStateToProps)(BudgetExpiryAlertAndModal);
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,5 @@ describe('<ContentHighlights>', () => {
data: { results: [{ applies_to_all_contexts: true }] },
}));
renderWithRouter(<ContentHighlightsWrapper location={{ state: {} }} />);
screen.debug();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol d'oh

});
});
4 changes: 2 additions & 2 deletions src/containers/EnterpriseApp/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { toggleSidebarToggle } from '../../data/actions/sidebar';

const mapStateToProps = (state) => {
const enterpriseListState = state.table['enterprise-list'] || {};

return {
enterprises: enterpriseListState.data,
error: state.portalConfiguration.error,
disableExpiryMessagingForLearnerCredit: state.portalConfiguration.disableExpiryMessagingForLearnerCredit,
enableCodeManagementScreen: state.portalConfiguration.enableCodeManagementScreen,
enableSubscriptionManagementScreen: state.portalConfiguration.enableSubscriptionManagementScreen, // eslint-disable-line max-len
enableSubscriptionManagementScreen: state.portalConfiguration.enableSubscriptionManagementScreen,
enableSamlConfigurationScreen: state.portalConfiguration.enableSamlConfigurationScreen,
enableAnalyticsScreen: state.portalConfiguration.enableAnalyticsScreen,
enableLearnerPortal: state.portalConfiguration.enableLearnerPortal,
Expand Down
4 changes: 4 additions & 0 deletions src/data/reducers/portalConfiguration.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const initialState = {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
Expand Down Expand Up @@ -50,6 +51,7 @@ const portalConfiguration = (state = initialState, action) => {
enterpriseSlug: action.payload.data.slug,
enterpriseBranding: action.payload.data.branding_configuration,
identityProvider: action.payload.data.identity_provider,
disableExpiryMessagingForLearnerCredit: action.payload.data.disable_expiry_messaging_for_learner_credit,
enableCodeManagementScreen: action.payload.data.enable_portal_code_management_screen,
enableReportingConfigScreen: action.payload.data.enable_portal_reporting_config_screen,
enableSubscriptionManagementScreen: action.payload.data.enable_portal_subscription_management_screen, // eslint-disable-line max-len
Expand All @@ -76,6 +78,7 @@ const portalConfiguration = (state = initialState, action) => {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
Expand All @@ -100,6 +103,7 @@ const portalConfiguration = (state = initialState, action) => {
enterpriseSlug: null,
enterpriseBranding: null,
identityProvider: null,
disableExpiryMessagingForLearnerCredit: false,
enableCodeManagementScreen: false,
enableReportingConfigScreen: false,
enableSubscriptionManagementScreen: false,
Expand Down
Loading
Loading