diff --git a/package.json b/package.json
index 24c672d..82b1ef9 100644
--- a/package.json
+++ b/package.json
@@ -78,7 +78,7 @@
"mini-css-extract-plugin": "^2.6.0",
"moment": "^2.22.2",
"moment-timezone": "^0.5.21",
- "openstack-uicore-foundation": "4.2.14",
+ "openstack-uicore-foundation": "4.2.31",
"optimize-css-assets-webpack-plugin": "^6.0.1",
"path": "^0.12.7",
"react": "^16.8.4",
@@ -104,6 +104,7 @@
"regenerator-runtime": "^0.13.7",
"sass": "^1.77.0",
"sass-loader": "^12.6.0",
+ "spark-md5": "^3.0.2",
"style-loader": "^3.3.1",
"superagent": "^3.8.1",
"sweetalert2": "^8.15.2",
@@ -129,7 +130,7 @@
"lodash": "^4.17.14",
"moment": "^2.22.2",
"moment-timezone": "^0.5.21",
- "openstack-uicore-foundation": "4.2.9",
+ "openstack-uicore-foundation": "4.2.31",
"react": "^16.8.4",
"react-bootstrap": "^0.31.5",
"react-datetime": "^2.16.2",
@@ -140,6 +141,7 @@
"redux": "^4.0.5",
"redux-persist": "^5.9.1",
"redux-thunk": "^2.3.0",
+ "spark-md5": "^3.0.2",
"superagent": "^3.8.1",
"sweetalert2": "^8.15.2",
"urijs": "^1.19.1",
diff --git a/src/actions.js b/src/actions.js
index c412110..4779099 100644
--- a/src/actions.js
+++ b/src/actions.js
@@ -47,7 +47,6 @@ export const GO_TO_LOGIN = 'GO_TO_LOGIN';
export const GET_MY_INVITATION = 'GET_MY_INVITATION';
export const CLEAR_MY_INVITATION = 'CLEAR_MY_INVITATION';
export const CLEAR_WIDGET_STATE = 'CLEAR_WIDGET_STATE';
-export const UPDATE_CLOCK = 'UPDATE_CLOCK';
export const LOAD_PROFILE_DATA = 'LOAD_PROFILE_DATA';
export const SET_CURRENT_PROMO_CODE = 'SET_CURRENT_PROMO_CODE';
@@ -525,6 +524,3 @@ export const getMyInvitation = (summitId) => async (dispatch, getState, { apiBas
}
}
-export const updateClock = (timestamp) => (dispatch) => {
- dispatch(createAction(UPDATE_CLOCK)({ timestamp }));
-};
diff --git a/src/components/purchase-complete/__tests__/purchase-complete.test.js b/src/components/purchase-complete/__tests__/purchase-complete.test.js
new file mode 100644
index 0000000..e7e03ad
--- /dev/null
+++ b/src/components/purchase-complete/__tests__/purchase-complete.test.js
@@ -0,0 +1,78 @@
+import React from 'react';
+import { cleanup, render, act } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+// Settable clock seed (the `mock` prefix is the only identifier jest.mock
+// allows the factory to capture via closure).
+let mockClockNow = 0;
+jest.mock('openstack-uicore-foundation/lib/components/clock-context', () => ({
+ useClockSelector: (selector) => selector(mockClockNow),
+}));
+
+// epochToMomentTimeZone returns an object with .format(). Keep it cheap and
+// deterministic so the test doesn't depend on moment / timezone data.
+jest.mock('openstack-uicore-foundation/lib/utils/methods', () => ({
+ epochToMomentTimeZone: jest.fn(() => ({ format: (fmt) => fmt === 'MMMM D' ? 'January 1' : '09:00 AM' })),
+}));
+
+jest.mock('openstack-uicore-foundation/lib/components/raw-html', () => (props) =>
{props.children}
);
+
+import PurchaseComplete from '..';
+
+const SUMMIT = {
+ start_date: 1_700_000_000, // ~Nov 2023
+ end_date: 1_700_086_400,
+ time_zone_id: 'UTC',
+ time_zone_label: 'UTC',
+};
+
+const defaultProps = {
+ checkout: { tickets: [{ owner: { email: 'john@example.com' }, badge: {} }] },
+ user: { email: 'john@example.com' },
+ onPurchaseComplete: jest.fn(),
+ goToExtraQuestions: jest.fn(),
+ goToEvent: jest.fn(),
+ goToMyOrders: jest.fn(),
+ // The component waits on this promise before rendering the branch we want
+ // to assert, so resolve it synchronously to a deterministic value.
+ completedExtraQuestions: jest.fn(() => Promise.resolve(false)),
+ summit: SUMMIT,
+ clearWidgetState: jest.fn(),
+ closeWidget: jest.fn(),
+ supportEmail: 'help@example.com',
+ hasVirtualAccessLevel: false,
+};
+
+afterEach(() => {
+ cleanup();
+ jest.clearAllMocks();
+ mockClockNow = 0;
+});
+
+const renderAndFlush = async (props = {}) => {
+ let utils;
+ await act(async () => {
+ utils = render();
+ });
+ return utils;
+};
+
+it('renders the active CTA path when the clock seed falls inside the summit window', async () => {
+ mockClockNow = SUMMIT.start_date + 1000;
+ const { queryByText } = await renderAndFlush();
+
+ // The "event will start on…" copy belongs to the inactive branch.
+ expect(queryByText(/event will start on/i)).not.toBeInTheDocument();
+ // The CTA in the active path with no required extra questions falls through
+ // to the My Orders/Tickets button.
+ expect(queryByText('View My Orders/Tickets')).toBeInTheDocument();
+});
+
+it('renders the "event will start" copy when the clock seed is outside the summit window', async () => {
+ mockClockNow = SUMMIT.end_date + 1; // one second past end
+ const { queryByText } = await renderAndFlush();
+
+ expect(queryByText(/The event will start on January 1 at 09:00 AM UTC/)).toBeInTheDocument();
+ // CTA still renders in the inactive branch (different layout).
+ expect(queryByText('View My Orders/Tickets')).toBeInTheDocument();
+});
diff --git a/src/components/purchase-complete/index.js b/src/components/purchase-complete/index.js
index 16ac756..5293a72 100644
--- a/src/components/purchase-complete/index.js
+++ b/src/components/purchase-complete/index.js
@@ -11,9 +11,10 @@
* limitations under the License.
**/
-import React, { useEffect, useState, useMemo } from 'react';
+import React, { useEffect, useState, useMemo, useCallback } from 'react';
import styles from './index.module.scss';
import { epochToMomentTimeZone } from 'openstack-uicore-foundation/lib/utils/methods';
+import { useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context';
import ContentLoader from 'react-content-loader';
import { isEmptyString, ticketHasAccessLevel } from '../../utils/utils';
import { VirtualAccessLevel } from '../../utils/constants';
@@ -39,7 +40,6 @@ const PurchaseComplete = ({
goToMyOrders,
completedExtraQuestions,
summit,
- nowUtc,
clearWidgetState,
closeWidget,
supportEmail,
@@ -54,7 +54,14 @@ const PurchaseComplete = ({
const [requireExtraQuestions, setRequireExtraQuestions] = useState(null);
const [extraQuestionsLoaded, setExtraQuestonsLoaded] = useState(false);
const isMultiOrder = useMemo(() => checkout?.tickets.length > 1, [checkout]);
- const isActive = useMemo(() => summit.start_date <= nowUtc && summit.end_date >= nowUtc, [summit, nowUtc]);
+ // Re-runs every clock tick but the boolean only changes on summit
+ // active/inactive transitions (typically zero times per session).
+ const isActive = useClockSelector(
+ useCallback(
+ (nowUtc) => summit.start_date <= nowUtc && summit.end_date >= nowUtc,
+ [summit.start_date, summit.end_date]
+ )
+ );
const currentTicket = useMemo(
() => isMultiOrder ? checkout?.tickets.find(t => t?.owner?.email === user?.email) : checkout?.tickets.find(t => t?.owner),
[user]
diff --git a/src/components/registration-form/__tests__/registration-form.test.js b/src/components/registration-form/__tests__/registration-form.test.js
index c2cd893..c84a91e 100644
--- a/src/components/registration-form/__tests__/registration-form.test.js
+++ b/src/components/registration-form/__tests__/registration-form.test.js
@@ -5,13 +5,22 @@ import { Provider } from 'react-redux';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
-// Mock withReduxProvider as identity HOC
-jest.mock('../../../utils/withReduxProvider', () => ({
- withReduxProvider: (Component) => Component,
+// Mock withWidgetProviders as identity HOC
+jest.mock('../../../utils/withWidgetProviders', () => ({
+ withWidgetProviders: (Component) => Component,
__esModule: true,
default: (Component) => Component,
}));
+// Stub the uicore clock-context so the selector runs once against a fixed
+// timestamp. The seed is settable per test via `mockClockNow` (the `mock`
+// prefix is the only identifier jest.mock allows the factory to capture).
+let mockClockNow = 1000000;
+jest.mock('openstack-uicore-foundation/lib/components/clock-context', () => ({
+ useClockSelector: (selector) => selector(mockClockNow),
+ ClockProvider: ({ children }) => children,
+}));
+
// Mock action creators
const mockChangeStep = jest.fn();
const mockClearWidgetState = jest.fn();
@@ -24,7 +33,6 @@ const mockGetLoginCode = jest.fn();
const mockPasswordlessLogin = jest.fn();
const mockGoToLogin = jest.fn();
const mockGetMyInvitation = jest.fn(() => Promise.resolve());
-const mockUpdateClock = jest.fn();
const mockLoadProfileData = jest.fn();
const mockApplyPromoCode = jest.fn();
const mockRemovePromoCode = jest.fn();
@@ -78,10 +86,6 @@ jest.mock('../../../actions', () => ({
mockGetMyInvitation(...args);
return Promise.resolve();
},
- updateClock: (...args) => {
- mockUpdateClock(...args);
- return { type: 'NOOP' };
- },
loadProfileData: (...args) => {
mockLoadProfileData(...args);
return { type: 'NOOP' };
@@ -117,10 +121,6 @@ jest.mock('openstack-uicore-foundation/lib/components/ajaxloader', () => {
return (props) => ;
});
-jest.mock('openstack-uicore-foundation/lib/components/clock', () => {
- return (props) => ;
-});
-
jest.mock('openstack-uicore-foundation/lib/security/constants', () => ({
AUTH_ERROR_MISSING_AUTH_INFO: 'Missing Auth info',
AUTH_ERROR_MISSING_REFRESH_TOKEN: 'missing Refresh Token',
@@ -130,7 +130,11 @@ jest.mock('openstack-uicore-foundation/lib/security/constants', () => ({
jest.mock('../../login', () => () => );
jest.mock('../../payment', () => () => );
jest.mock('../../personal-information', () => () => );
-jest.mock('../../ticket-type', () => () => );
+const mockTicketTypeProps = { current: null };
+jest.mock('../../ticket-type', () => (props) => {
+ mockTicketTypeProps.current = props;
+ return ;
+});
jest.mock('../../button-bar', () => () => );
jest.mock('../../purchase-complete', () => () => );
jest.mock('../../login-passwordless', () => () => );
@@ -154,7 +158,7 @@ jest.mock('react-use', () => ({
useMeasure: () => [jest.fn(), { height: 100 }],
}));
-// Import default (which is withReduxProvider(RegistrationForm) but our mock makes it identity)
+// Import default (which is withWidgetProviders(RegistrationForm) but our mock makes it identity)
import RegistrationForm from '..';
const STEP_SELECT_TICKET_TYPE = 0;
@@ -180,7 +184,6 @@ const defaultReduxState = {
summitId: null,
userProfile: null,
},
- nowUtc: 1000000,
promoCode: '',
promoCodeVerified: null,
promoCodeValidating: false,
@@ -240,6 +243,8 @@ const renderWithStore = (props = {}, stateOverrides = {}) => {
afterEach(() => {
cleanup();
jest.clearAllMocks();
+ mockClockNow = 1000000;
+ mockTicketTypeProps.current = null;
});
it('closeHandlerRef is assigned handleCloseClick via useEffect', () => {
@@ -411,3 +416,51 @@ it('refires ticket-types fetch and promo discovery when summit id changes', asyn
expect(mockDiscoverPromoCodes).toHaveBeenCalledTimes(1);
expect(mockDiscoverPromoCodes).toHaveBeenCalledWith(2);
});
+
+// Exercises isTicketCurrentlyAvailable through the rendered prop, with the
+// clock seed deliberately landing inside one ticket's sales window and outside
+// the other's. Without this, a regression in the helper would slip through
+// because the rest of the suite uses a 1970 seed against which every realistic
+// window evaluates to false.
+it('allowedTicketTypes contains the ticket whose sales window covers the clock seed', async () => {
+ mockClockNow = 1700000000; // 2023-11-14, well inside the in-window fixture
+ const inWindow = {
+ id: 101,
+ name: 'In Window',
+ sub_type: 'Regular',
+ sales_start_date: 1699000000,
+ sales_end_date: 1701000000,
+ };
+ const expired = {
+ id: 102,
+ name: 'Expired',
+ sub_type: 'Regular',
+ sales_start_date: 1690000000,
+ sales_end_date: 1695000000,
+ };
+ const alwaysOpen = {
+ id: 103,
+ name: 'Always Open',
+ sub_type: 'Regular',
+ sales_start_date: null,
+ sales_end_date: null,
+ };
+ const prepaidExpired = {
+ id: 104,
+ name: 'Prepaid Past',
+ sub_type: 'PrePaid',
+ sales_start_date: 1690000000,
+ sales_end_date: 1695000000,
+ };
+
+ renderWithStore(
+ { ownedTickets: [] },
+ { ticketTypes: [inWindow, expired, alwaysOpen, prepaidExpired] },
+ );
+ await act(async () => {});
+
+ expect(mockTicketTypeProps.current).not.toBeNull();
+ const ids = mockTicketTypeProps.current.allowedTicketTypes.map((t) => t.id);
+ expect(ids).toEqual(expect.arrayContaining([101, 103, 104]));
+ expect(ids).not.toContain(102);
+});
diff --git a/src/components/registration-form/index.js b/src/components/registration-form/index.js
index b2f2fcb..6c96be3 100644
--- a/src/components/registration-form/index.js
+++ b/src/components/registration-form/index.js
@@ -14,9 +14,10 @@
**/
import React, { useEffect, useState, useMemo, useCallback } from 'react';
-import { connect } from "react-redux";
+import { connect, shallowEqual } from "react-redux";
import PropTypes from 'prop-types';
-import { withReduxProvider } from '../../utils/withReduxProvider';
+import { withWidgetProviders } from '../../utils/withWidgetProviders';
+import { useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context';
import { animated, config, useSpring } from "react-spring";
import { useMeasure } from "react-use";
import {
@@ -37,7 +38,6 @@ import {
removeReservedTicket,
reserveTicket,
clearWidgetState,
- updateClock,
loadProfileData,
removePromoCode,
applyPromoCode,
@@ -50,7 +50,6 @@ import {
import usePromoCode from '../../hooks/usePromoCode';
import AjaxLoader from "openstack-uicore-foundation/lib/components/ajaxloader";
-import Clock from "openstack-uicore-foundation/lib/components/clock";
import '../../styles/styles.scss';
@@ -87,6 +86,14 @@ try {
T.setTexts(require(`../../i18n/en.json`));
}
+const isTicketCurrentlyAvailable = (tt, nowUtc) =>
+ // prepaid tickets are always available (sales windows don't apply)
+ tt.sub_type === TICKET_TYPE_SUBTYPE_PREPAID ||
+ // no sales window configured → ticket is open-ended, always available
+ (tt.sales_start_date === null && tt.sales_end_date === null) ||
+ // ticket is within its configured sales window
+ (nowUtc >= tt.sales_start_date && nowUtc <= tt.sales_end_date);
+
const RegistrationFormContent = (
{
@@ -142,8 +149,6 @@ const RegistrationFormContent = (
allowPromoCodes,
showCompanyInput,
companyDDLPlaceholder,
- nowUtc,
- updateClock,
completedExtraQuestions,
loadProfileData,
closeWidget,
@@ -200,7 +205,18 @@ const RegistrationFormContent = (
const { publicKey, provider } = getCurrentProvider(summitData);
- const allowedTicketTypes = useMemo(() => hasTicketData ? ticketTypes.filter((tt) => tt.sub_type === TICKET_TYPE_SUBTYPE_PREPAID || (tt.sales_start_date === null && tt.sales_end_date === null) || (nowUtc >= tt.sales_start_date && nowUtc <= tt.sales_end_date)) : [], [hasTicketData, ticketTypes, nowUtc]);
+ // Re-runs every clock tick but only commits a new array when the filtered
+ // contents shift (a sale window opens/closes), so the form tree stops
+ // re-rendering once per second.
+ const allowedTicketTypes = useClockSelector(
+ useCallback(
+ (nowUtc) => hasTicketData
+ ? ticketTypes.filter(tt => isTicketCurrentlyAvailable(tt, nowUtc))
+ : [],
+ [hasTicketData, ticketTypes]
+ ),
+ shallowEqual
+ );
const alreadyOwnedTickets = useMemo(() => isAuthenticated && hasTicketData && !ticketDataError && allowedTicketTypes.length > 0 && ownedTickets.length > 0, [isAuthenticated, hasTicketData, ticketDataError, allowedTicketTypes, ownedTickets]);
@@ -375,7 +391,6 @@ const RegistrationFormContent = (
return (
-
updateClock(timestamp)} timezone={summitData.time_zone_id} />
{profileData && ticketDataError && handleGetTicketTypesAndTaxes(summitData?.id)} />}
@@ -515,7 +530,6 @@ const RegistrationFormContent = (
goToMyOrders={goToMyOrders}
goToExtraQuestions={goToExtraQuestions}
completedExtraQuestions={completedExtraQuestions}
- nowUtc={nowUtc}
clearWidgetState={clearWidgetState}
closeWidget={closeWidget}
hasVirtualAccessLevel={hasVirtualAccessLevel}
@@ -549,7 +563,6 @@ const mapStateToProps = ({ registrationLiteState }) => ({
passwordlessCodeLifeTime: registrationLiteState.passwordless.otp_lifetime,
passwordlessCodeSent: registrationLiteState.passwordless.code_sent,
passwordlessCodeError: registrationLiteState.passwordless.error,
- nowUtc: registrationLiteState.nowUtc,
promoCode: registrationLiteState.promoCode,
promoCodeVerified: registrationLiteState.promoCodeVerified,
promoCodeValidating: registrationLiteState.promoCodeValidating,
@@ -569,7 +582,6 @@ const RegistrationForm = connect(mapStateToProps, {
goToLogin,
getMyInvitation,
clearWidgetState,
- updateClock,
loadProfileData,
applyPromoCode,
removePromoCode,
@@ -628,4 +640,4 @@ RegistrationForm.propTypes = {
};
export { RegistrationForm };
-export default withReduxProvider(RegistrationForm);
+export default withWidgetProviders(RegistrationForm);
diff --git a/src/components/registration-modal/__tests__/registration-modal.test.js b/src/components/registration-modal/__tests__/registration-modal.test.js
index bed1a89..d11b3ae 100644
--- a/src/components/registration-modal/__tests__/registration-modal.test.js
+++ b/src/components/registration-modal/__tests__/registration-modal.test.js
@@ -2,9 +2,9 @@ import React from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import '@testing-library/jest-dom';
-// Mock withReduxProvider as identity HOC (modal default export wraps with it)
-jest.mock('../../../utils/withReduxProvider', () => ({
- withReduxProvider: (Component) => Component,
+// Mock withWidgetProviders as identity HOC (modal default export wraps with it)
+jest.mock('../../../utils/withWidgetProviders', () => ({
+ withWidgetProviders: (Component) => Component,
__esModule: true,
default: (Component) => Component,
}));
diff --git a/src/components/registration-modal/index.js b/src/components/registration-modal/index.js
index ad48efb..4169443 100644
--- a/src/components/registration-modal/index.js
+++ b/src/components/registration-modal/index.js
@@ -16,7 +16,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { RegistrationForm } from '../registration-form';
-import { withReduxProvider } from '../../utils/withReduxProvider';
+import { withWidgetProviders } from '../../utils/withWidgetProviders';
import styles from "../../styles/general.module.scss";
const RegistrationModal = ({ summitData, closeWidget, ...props }) => {
@@ -93,4 +93,4 @@ RegistrationModal.propTypes = {
companyDDLOptions2Show: PropTypes.number,
};
-export default withReduxProvider(RegistrationModal);
+export default withWidgetProviders(RegistrationModal);
diff --git a/src/reducer.js b/src/reducer.js
index ae3f53f..b6549f5 100644
--- a/src/reducer.js
+++ b/src/reducer.js
@@ -31,7 +31,6 @@ import {
CLEAR_MY_INVITATION,
CLEAR_WIDGET_STATE,
REQUESTED_TICKET_TYPES,
- UPDATE_CLOCK,
LOAD_PROFILE_DATA,
SET_CURRENT_PROMO_CODE,
CLEAR_CURRENT_PROMO_CODE,
@@ -43,12 +42,8 @@ import {
} from './actions';
import { LOGOUT_USER } from 'openstack-uicore-foundation/lib/security/actions';
-import moment from 'moment';
import { STEP_SELECT_TICKET_TYPE } from './utils/constants';
-const localNowUtc = moment().unix();
-
-
const DEFAULT_STATE = {
reservation: null,
checkout: null,
@@ -72,7 +67,6 @@ const DEFAULT_STATE = {
summitId: null,
userProfile: null,
},
- nowUtc: localNowUtc,
promoCode: '',
promoCodeVerified: null,
promoCodeValidating: false,
@@ -169,10 +163,6 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => {
case CLEAR_MY_INVITATION: {
return { ...state, invitation: null };
}
- case UPDATE_CLOCK: {
- const { timestamp } = payload;
- return { ...state, nowUtc: timestamp };
- }
case CLEAR_CURRENT_PROMO_CODE: {
return { ...state, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true }
}
diff --git a/src/utils/__tests__/withReduxProvider.test.js b/src/utils/__tests__/withWidgetProviders.test.js
similarity index 66%
rename from src/utils/__tests__/withReduxProvider.test.js
rename to src/utils/__tests__/withWidgetProviders.test.js
index 4d997b5..c70ff07 100644
--- a/src/utils/__tests__/withReduxProvider.test.js
+++ b/src/utils/__tests__/withWidgetProviders.test.js
@@ -3,8 +3,9 @@ import { cleanup, render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Provider, connect } from 'react-redux';
import { createStore, combineReducers } from 'redux';
+import { useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context';
-import { withReduxProvider } from '../withReduxProvider';
+import { withWidgetProviders } from '../withWidgetProviders';
// Mock the store module
const mockStore = { getState: jest.fn(), subscribe: jest.fn(), dispatch: jest.fn() };
@@ -31,7 +32,7 @@ const StubComponent = ({ clientId, apiBaseUrl, getAccessToken, ...rest }) => {
- const Wrapped = withReduxProvider(StubComponent);
+ const Wrapped = withWidgetProviders(StubComponent);
const { getByTestId } = render(
'token'} />
);
@@ -42,7 +43,7 @@ it('renders with Provider and creates a store via getStore/getPersistor', () =>
});
it('passes props through to the wrapped component', () => {
- const Wrapped = withReduxProvider(StubComponent);
+ const Wrapped = withWidgetProviders(StubComponent);
const { getByTestId } = render(
'token'} data-custom="hello" />
);
@@ -52,7 +53,7 @@ it('passes props through to the wrapped component', () => {
});
it('caches store across re-renders (class constructor runs once)', () => {
- const Wrapped = withReduxProvider(StubComponent);
+ const Wrapped = withWidgetProviders(StubComponent);
const { rerender } = render(
'token'} />
);
@@ -68,13 +69,64 @@ it('caches store across re-renders (class constructor runs once)', () => {
});
it('sets displayName based on wrapped component', () => {
- const Wrapped = withReduxProvider(StubComponent);
- expect(Wrapped.displayName).toBe('WithReduxProvider(StubComponent)');
+ const Wrapped = withWidgetProviders(StubComponent);
+ expect(Wrapped.displayName).toBe('WithWidgetProviders(StubComponent)');
});
it('uses fallback displayName for anonymous component', () => {
- const Wrapped = withReduxProvider(() => );
- expect(Wrapped.displayName).toBe('WithReduxProvider(Component)');
+ const Wrapped = withWidgetProviders(() => );
+ expect(Wrapped.displayName).toBe('WithWidgetProviders(Component)');
+});
+
+it('provides ClockProvider so useClockSelector resolves against a live timestamp', () => {
+ const ClockConsumer = () => {
+ const year = useClockSelector((nowUtc) =>
+ nowUtc ? new Date(nowUtc * 1000).getUTCFullYear() : null
+ );
+ return {year ?? 'null'}
;
+ };
+ const Wrapped = withWidgetProviders(ClockConsumer);
+ const { getByTestId } = render(
+ 'token'}
+ summitData={{ time_zone_id: 'UTC' }}
+ />
+ );
+ expect(Number(getByTestId('year').textContent)).toBeGreaterThanOrEqual(2024);
+});
+
+it('keeps useClockSelector live after summitData.time_zone_id changes', () => {
+ // PureComponent re-renders on prop change, which feeds a fresh `timezone`
+ // and `now` into ClockProvider. The consumer must still resolve to a
+ // valid timestamp after that propagation.
+ const ClockConsumer = () => {
+ const year = useClockSelector((nowUtc) =>
+ nowUtc ? new Date(nowUtc * 1000).getUTCFullYear() : null
+ );
+ return {year ?? 'null'}
;
+ };
+ const Wrapped = withWidgetProviders(ClockConsumer);
+ const { getByTestId, rerender } = render(
+ 'token'}
+ summitData={{ time_zone_id: 'UTC' }}
+ />
+ );
+ expect(Number(getByTestId('year').textContent)).toBeGreaterThanOrEqual(2024);
+
+ rerender(
+ 'token'}
+ summitData={{ time_zone_id: 'America/New_York' }}
+ />
+ );
+ expect(Number(getByTestId('year').textContent)).toBeGreaterThanOrEqual(2024);
});
describe('REGRESSION: widget works under a foreign Provider', () => {
@@ -115,7 +167,7 @@ describe('REGRESSION: widget works under a foreign Provider', () => {
// Override the mock so the HOC's getStore returns our real store
mockGetStore.mockReturnValue(realStore);
- const WrappedWithHOC = withReduxProvider(ConnectedComponent);
+ const WrappedWithHOC = withWidgetProviders(ConnectedComponent);
// Create a FOREIGN store that does NOT have registrationLiteState
const foreignStore = createStore(
diff --git a/src/utils/withReduxProvider.js b/src/utils/withWidgetProviders.js
similarity index 54%
rename from src/utils/withReduxProvider.js
rename to src/utils/withWidgetProviders.js
index a191d29..5ed719b 100644
--- a/src/utils/withReduxProvider.js
+++ b/src/utils/withWidgetProviders.js
@@ -10,38 +10,44 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*
- * HOC to wrap a component with Redux Provider and PersistGate.
+ * HOC to wrap a widget root with Redux, PersistGate, and ClockProvider.
**/
import React from 'react';
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
+import { ClockProvider } from "openstack-uicore-foundation/lib/components/clock-context";
import { getStore, getPersistor } from "../store";
-export const withReduxProvider = (WrappedComponent) => {
- class WithReduxProvider extends React.PureComponent {
+export const withWidgetProviders = (WrappedComponent) => {
+ class WithWidgetProviders extends React.PureComponent {
constructor(props) {
super(props);
this.store = getStore(props.clientId, props.apiBaseUrl, props.getAccessToken);
}
render() {
+ const { summitData } = this.props;
return (
-
+
+
+
);
}
}
- // Copy propTypes and defaultProps from wrapped component
- WithReduxProvider.propTypes = WrappedComponent.propTypes;
- WithReduxProvider.defaultProps = WrappedComponent.defaultProps;
- WithReduxProvider.displayName = `WithReduxProvider(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
+ WithWidgetProviders.propTypes = WrappedComponent.propTypes;
+ WithWidgetProviders.defaultProps = WrappedComponent.defaultProps;
+ WithWidgetProviders.displayName = `WithWidgetProviders(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
- return WithReduxProvider;
+ return WithWidgetProviders;
};
-export default withReduxProvider;
+export default withWidgetProviders;
diff --git a/yarn.lock b/yarn.lock
index d42148f..e82d732 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7512,10 +7512,12 @@ open@^8.0.9:
is-docker "^2.1.1"
is-wsl "^2.2.0"
-openstack-uicore-foundation@4.2.14:
- version "4.2.14"
- resolved "https://registry.npmjs.org/openstack-uicore-foundation/-/openstack-uicore-foundation-4.2.14.tgz#d759a6358ede869f356fdf387065632b341bcf6a"
- integrity sha512-ECwPZ7QOUrhW/fKbyWUfsYx/x207WPuLx9VfDLS2i46p3CoD+z6XT2a4AoUzYBoOa9iRdhXAtJUsymN8sGIzYA==
+openstack-uicore-foundation@4.2.31:
+ version "4.2.31"
+ resolved "https://registry.yarnpkg.com/openstack-uicore-foundation/-/openstack-uicore-foundation-4.2.31.tgz#593b12ee1cd80cfa299f813b2470dcbde46cc580"
+ integrity sha512-DE44A0hr5mM9OCak8h4uEdNSjqBzeI+9yPf1/J+TgfzJPtZNrkW2+y43Z8jIHoByI6lBOCug31FuAo6ysDyaiw==
+ dependencies:
+ use-sync-external-store "^1.6.0"
optimize-css-assets-webpack-plugin@^6.0.1:
version "6.0.1"
@@ -9458,6 +9460,11 @@ sourcemap-codec@^1.4.8:
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
+spark-md5@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc"
+ integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==
+
spdx-correct@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
@@ -10171,6 +10178,11 @@ use-debounce@^6.0.1:
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-6.0.1.tgz#ed1eb2b30189408fb9792ea2887f4c6c3cb401a3"
integrity sha512-kpvIxpa0vOLz/2I2sfNJ72mUeaT2CMNCu5BT1f2HkV9qZK27UVSOFf1sSSu+wjJE4TcR2VTXS2SM569+m3TN7Q==
+use-sync-external-store@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
+ integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
+
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"