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"