Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,29 @@ it('renders the active CTA path when the clock seed falls inside the summit wind
expect(queryByText('View My Orders/Tickets')).toBeInTheDocument();
});

it('falls back to the default label when orderCompleteButton is passed as undefined', async () => {
mockClockNow = SUMMIT.start_date + 1000;
// A present-but-undefined prop (marketing key with no value) must not blank
// the button — isEmptyString(undefined) is false, so without the typeof
// guard the branch returned undefined and rendered an empty button.
const { queryByText } = await renderAndFlush({ orderCompleteButton: undefined });

expect(queryByText('View My Orders/Tickets')).toBeInTheDocument();
});

it('interpolates {button} in a marketing-override paragraph', async () => {
mockClockNow = SUMMIT.start_date + 1000;
// A custom paragraph that references the button by token must print the
// resolved label, not the literal {button}.
const { queryByText } = await renderAndFlush({
orderCompleteButton: 'Wrap Up',
initialOrderComplete1stParagraph: 'Please click the "{button}" button.',
});

expect(queryByText('Please click the "Wrap Up" button.')).toBeInTheDocument();
expect(queryByText(/\{button\}/)).not.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();
Expand Down
28 changes: 14 additions & 14 deletions src/components/purchase-complete/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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 { isEmptyString, interpolate, ticketHasAccessLevel } from '../../utils/utils';
import { VirtualAccessLevel } from '../../utils/constants';
import T from 'i18n-react';
import RawHTML from 'openstack-uicore-foundation/lib/components/raw-html';
Expand Down Expand Up @@ -106,8 +106,7 @@ const PurchaseComplete = ({

let orderCompleteButtonText = (
currentTicket && requireExtraQuestions ?
rest.hasOwnProperty('initialOrderCompleteButton') && !isEmptyString(rest.initialOrderCompleteButton)
&& typeof rest.initialOrderCompleteButton !== 'undefined' ?
rest.hasOwnProperty('initialOrderCompleteButton') && !isEmptyString(rest.initialOrderCompleteButton) ?
rest.initialOrderCompleteButton
:
T.translate('purchase_complete_step.initial_order_complete_button')
Expand All @@ -119,28 +118,29 @@ const PurchaseComplete = ({
);

let orderCompleteTitle = (
rest.hasOwnProperty('orderCompleteTitle') && !isEmptyString(rest.orderCompleteTitle)
&& typeof rest.orderCompleteTitle !== 'undefined' ?
rest.hasOwnProperty('orderCompleteTitle') && !isEmptyString(rest.orderCompleteTitle) ?
rest.orderCompleteTitle
:
T.translate('purchase_complete_step.title')
);

// Shared with both the i18n default and the marketing override so custom
// copy can use the same {attendee}/{adv}/{button} tokens.
const paragraphVars = {
attendee: `${attendeeIsSomeoneElse ? ` ${currentTicket.owner.email}` : 'you'}`,
adv: `${attendeeIsSomeoneElse ? `${currentTicket.owner.email}` : 'your'}`,
button: orderCompleteButtonText
};

let orderComplete1stParagraph = (
currentTicket ?
!attendeeIsSomeoneElse && rest.hasOwnProperty('initialOrderComplete1stParagraph') && typeof rest.initialOrderComplete1stParagraph !== 'undefined' ?
rest.initialOrderComplete1stParagraph
interpolate(rest.initialOrderComplete1stParagraph, paragraphVars)
:
T.translate('purchase_complete_step.initial_order_complete_1st_paragraph_label',
{
attendee: `${attendeeIsSomeoneElse ? ` ${currentTicket.owner.email}` : 'you'}`,
adv: `${attendeeIsSomeoneElse ? `${currentTicket.owner.email}` : 'your'}`,
button: orderCompleteButtonText
}
)
T.translate('purchase_complete_step.initial_order_complete_1st_paragraph_label', paragraphVars)
:
rest.hasOwnProperty('orderComplete1stParagraph') && typeof rest.orderComplete1stParagraph !== 'undefined' ?
rest.orderComplete1stParagraph
interpolate(rest.orderComplete1stParagraph, paragraphVars)
:
T.translate('purchase_complete_step.order_complete_1st_paragraph_label')
);
Expand Down
52 changes: 52 additions & 0 deletions src/utils/__tests__/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { isEmptyString, interpolate } from '../utils';

describe('isEmptyString', () => {
it('treats missing values (null/undefined) as empty', () => {
expect(isEmptyString(undefined)).toBe(true);
expect(isEmptyString(null)).toBe(true);
});

it('treats empty and whitespace-only strings as empty', () => {
expect(isEmptyString('')).toBe(true);
expect(isEmptyString(' ')).toBe(true);
});

it('treats a non-empty string as not empty', () => {
expect(isEmptyString('Finish Now')).toBe(false);
expect(isEmptyString(' x ')).toBe(false);
});
});

describe('interpolate', () => {
it('replaces every occurrence of a {token} with its value', () => {
expect(interpolate('click the "{button}" button', { button: 'Finish Now' }))
.toBe('click the "Finish Now" button');
expect(interpolate('{a} and {a}', { a: 'x' })).toBe('x and x');
});

it('replaces multiple distinct tokens', () => {
expect(interpolate('assigned to {attendee}, complete {adv} details', { attendee: 'you', adv: 'your' }))
.toBe('assigned to you, complete your details');
});

it('leaves unknown tokens untouched', () => {
expect(interpolate('hello {name}', { button: 'x' })).toBe('hello {name}');
});

it('returns a non-string template unchanged', () => {
expect(interpolate(undefined, { a: '1' })).toBe(undefined);
});

it('does not substitute into a value it just inserted', () => {
// Values come from marketing overrides, so one that happens to contain
// a token must land as written rather than be expanded in turn.
expect(interpolate('{attendee} pays', { attendee: '{button}', button: 'Finish Now' }))
.toBe('{button} pays');
});

it('is unaffected by the shared regex across calls', () => {
const call = () => interpolate('{a} {a}', { a: 'x' });
expect(call()).toBe('x x');
expect(call()).toBe('x x');
});
});
18 changes: 17 additions & 1 deletion src/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,23 @@ export const getCurrentUserLanguage = () => {
};

export const isEmptyString = (val) => {
return typeof val === 'string' && val.trim().length == 0;
// A missing value (null/undefined) counts as empty too, so callers can guard
// an optional override prop with a single `!isEmptyString(prop)` check.
return val == null || (typeof val === 'string' && val.trim().length == 0);
}

// Replaces {token} placeholders in a template string with values from `vars`.
// Used so marketing-override copy supports the same {attendee}/{adv}/{button}
// tokens the built-in i18n strings do. Unknown tokens are left untouched.
// One pass over the template, so a value that itself contains a {token} is
// inserted as-is rather than being substituted again by a later pass.
const TOKEN = /\{(\w+)\}/g;

export const interpolate = (template, vars = {}) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe you can avoid looping over the template over and over again with something like this:

const TOKEN = /\{(\w+)\}/g;
export const interpolate = (template, vars = {}) => {
    if (typeof template !== 'string') return template;
    return template.replace(TOKEN, (match, key) =>
        key in vars ? String(vars[key]) : match
    );
};```

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 8acd6e6, thanks. Single regex pass now.

It also fixes a bug: with the old loop, a value containing a {token} got expanded by the next pass. Added a test for it.

if (typeof template !== 'string') return template;
return template.replace(TOKEN, (match, key) =>
key in vars ? String(vars[key]) : match
);
}

export const getTicketTaxes = (ticket, taxes) => {
Expand Down
Loading