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

Add minimum budget limit #2583

Open
wants to merge 55 commits into
base: update/2535-consolidate-ad-creation-ccf-merged
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
f6c9f55
Updates validateCampaign with daily limit check
dsawardekar Sep 5, 2024
dbfb972
Adds validateCampaign tests
dsawardekar Sep 5, 2024
fb36ca6
Adds getHighestBudget utility
dsawardekar Sep 5, 2024
41956ca
Adds daily limit data lookup to paid ads setup sections
dsawardekar Sep 5, 2024
9ef16c2
Fixes linter warnings
dsawardekar Sep 5, 2024
5741e6b
Style error message accordingly.
asvinb Sep 6, 2024
ac7753c
Merge branch 'feature/2459-campaign-creation-flow' into update/2459-a…
dsawardekar Sep 11, 2024
beb3700
Adds e2e tests
dsawardekar Sep 11, 2024
c7f5a36
Merge branch 'feature/2459-campaign-creation-flow' into update/2459-a…
dsawardekar Sep 12, 2024
1d41c1b
Uses form opts instead of values
dsawardekar Sep 13, 2024
585bbbd
Update countryCodes and validation callback
dsawardekar Sep 13, 2024
f35ac9d
Updates unit tests
dsawardekar Sep 13, 2024
6804415
Fixes linter warnings
dsawardekar Sep 13, 2024
d62bc02
Do not pass uneeded props.
asvinb Sep 16, 2024
603d9c4
Format currency for recommended budget.
asvinb Sep 17, 2024
f73150b
Update E2E test.
asvinb Sep 17, 2024
f1fd765
Fix E2E test.
asvinb Sep 17, 2024
7adc092
Fix e2e test.
asvinb Sep 17, 2024
90e8d95
Add useValidateCampaignWithCountryCodes hook.
asvinb Sep 18, 2024
811461f
Fix unit test.
asvinb Sep 18, 2024
eb70d33
Do not trigger fetch if there are no countryCodes.
asvinb Sep 19, 2024
a502b7a
Add loading property to know when the hook is ready.
asvinb Sep 19, 2024
34c58a9
Remove condition to display PaidAdsSetupSections.
asvinb Sep 19, 2024
5d794ed
Merge branch 'feature/2459-campaign-creation-flow' into update/2459-a…
asvinb Sep 23, 2024
4d80b45
Add getAdsBudgetRecommendations selector.
asvinb Sep 23, 2024
44d9eed
Update hooks.
asvinb Sep 23, 2024
29b6920
Add JSDocs.
asvinb Sep 23, 2024
a1a5cf4
More descriptive JSDocs.
asvinb Sep 23, 2024
a7a2eba
Fix linting errors.
asvinb Sep 23, 2024
684fd02
Apply change.
asvinb Sep 24, 2024
447bff0
Add JSdocs for returned value.
asvinb Sep 24, 2024
c85bdf1
Fix JS errors.
asvinb Sep 24, 2024
74e0dd8
Fix e2e test.
asvinb Sep 24, 2024
6fc4a6b
Address CR feedback.
asvinb Sep 24, 2024
716581b
Add JSDocs.
asvinb Sep 24, 2024
2c4f863
Simplify hooks.
asvinb Sep 24, 2024
2d51b4a
Fix JS tests.
asvinb Sep 24, 2024
ab6a397
Rename function.
asvinb Sep 24, 2024
edccd57
Delete unused file.
asvinb Sep 24, 2024
7f7261a
Review comments.
asvinb Sep 24, 2024
330697c
Update minimum amount displayed to the user.
asvinb Sep 24, 2024
0d8c6bd
Make sure country codes are loaded.
asvinb Sep 26, 2024
5f2f291
Fix e2e test.
asvinb Sep 26, 2024
03b1167
Take precision settings into consideration.
asvinb Sep 27, 2024
7432760
Fix e2e tests.
asvinb Sep 27, 2024
f23a52c
Convert to float.
asvinb Sep 27, 2024
df07674
Merge branch 'feature/2459-campaign-creation-flow' into update/2459-a…
asvinb Oct 1, 2024
d1ed9cf
Merge branch 'update/2535-consolidate-ad-creation-ccf-merged' into up…
asvinb Oct 1, 2024
dfb1bc9
Fix bad merge.
asvinb Oct 1, 2024
c89ac46
Simplify code where countryCodes is needed.
asvinb Oct 1, 2024
6d7a287
Remove unused props.
asvinb Oct 1, 2024
3ff8276
Fix loaded value
asvinb Oct 1, 2024
78fc8bb
Fix e2e test
asvinb Oct 1, 2024
3fb0832
Use Math.ceil.
asvinb Oct 4, 2024
ce9ad99
Fix failing test.
asvinb Oct 4, 2024
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
2 changes: 2 additions & 0 deletions js/src/components/app-input-control/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@
color: $gray-700;
}

&.has-error .components-input-control__backdrop,
&--error-character-count .components-input-control .components-input-control__container .components-input-control__backdrop {
border-color: $alert-red;
box-shadow: none;
}

&.has-error .components-base-control__help,
&--error-character-count &__character-count {
color: $alert-red;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* Internal dependencies
*/
import useCountryKeyNameMap from '.~/hooks/useCountryKeyNameMap';
import useFetchBudgetRecommendationEffect from './useFetchBudgetRecommendationEffect';
import useFetchBudgetRecommendation from '.~/hooks/useFetchBudgetRecommendation';
import './index.scss';

/*
Expand Down Expand Up @@ -51,7 +51,7 @@

const BudgetRecommendation = ( props ) => {
const { countryCodes, dailyAverageCost = Infinity } = props;
const { data } = useFetchBudgetRecommendationEffect( countryCodes );
const { data } = useFetchBudgetRecommendation( countryCodes );

Check warning on line 54 in js/src/components/paid-ads/budget-section/budget-recommendation/index.js

View check run for this annotation

Codecov / codecov/patch

js/src/components/paid-ads/budget-section/budget-recommendation/index.js#L54

Added line #L54 was not covered by tests
const map = useCountryKeyNameMap();

if ( ! data ) {
Expand Down

This file was deleted.

16 changes: 14 additions & 2 deletions js/src/components/paid-ads/campaign-assets-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
* External dependencies
*/
import { useState, useMemo } from '@wordpress/element';
import { noop } from 'lodash';

/**
* Internal dependencies
*/
import { ASSET_GROUP_KEY, ASSET_FORM_KEY } from '.~/constants';
import AdaptiveForm from '.~/components/adaptive-form';
import validateCampaign from '.~/components/paid-ads/validateCampaign';
import validateAssetGroup from '.~/components/paid-ads/validateAssetGroup';
import useValidateCampaignWithCountryCodes from '.~/hooks/useValidateCampaignWithCountryCodes';

/**
* @typedef {import('.~/components/types.js').CampaignFormValues} CampaignFormValues
Expand Down Expand Up @@ -64,11 +65,13 @@
* @augments AdaptiveForm
* @param {Object} props React props.
* @param {CampaignFormValues} props.initialCampaign Initial campaign values.
* @param {Function} [props.onChange] Callback when the form values change.
* @param {AssetEntityGroup} [props.assetEntityGroup] The asset entity group to be used in initializing the form values for editing.
*/
export default function CampaignAssetsForm( {
initialCampaign,
assetEntityGroup,
onChange = noop,
...adaptiveFormProps
} ) {
const initialAssetGroup = useMemo( () => {
Expand All @@ -77,6 +80,14 @@

const [ baseAssetGroup, setBaseAssetGroup ] = useState( initialAssetGroup );
const [ hasImportedAssets, setHasImportedAssets ] = useState( false );
const [ countryCodes, setCountryCodes ] = useState( [] );
const { validateCampaignWithCountryCodes } =
useValidateCampaignWithCountryCodes( countryCodes );

const handleOnChange = ( _, values, arg ) => {
setCountryCodes( values.countryCodes );
Copy link
Member

Choose a reason for hiding this comment

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

The CampaignAssetsForm already has form data initialized by initialCampaign.countryCodes holding the countryCodes value. Adding another countryCodes state to the form and listening to the form's updates to set its new state makes the overall state management more complex and confusing.

In addition, the current implementation doesn't work correctly. For example, two problems can be seen in the following video:

  1. Enter 4 first. When the minimum budget limit is changed from 5 to 4, the error message continues to be displayed.
  2. Enter 4 first. When the minimum budget limit is changed from 4 to 5, the error message is not displayed and it can continue to the next step.
Kapture.2024-09-24.at.13.54.39.mp4

Copy link
Collaborator

Choose a reason for hiding this comment

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

@eason9487 The main reason why I had a state variable for countryCodes is because we need to know when the selected countries are updated so that we can fetch the latest recommendations. I've changed the approach a bit by having the state variable within the useValidateCampaignWithCountryCodes where you can pass updated country codes via the refreshCountryCodes function from the hook.

onChange( _, values, arg );

Check warning on line 89 in js/src/components/paid-ads/campaign-assets-form.js

View check run for this annotation

Codecov / codecov/patch

js/src/components/paid-ads/campaign-assets-form.js#L88-L89

Added lines #L88 - L89 were not covered by tests
eason9487 marked this conversation as resolved.
Show resolved Hide resolved
};

const extendAdapter = ( formContext ) => {
const assetGroupErrors = validateAssetGroup( formContext.values );
Expand Down Expand Up @@ -120,8 +131,9 @@
...initialCampaign,
...initialAssetGroup,
} }
validate={ validateCampaign }
validate={ validateCampaignWithCountryCodes }
extendAdapter={ extendAdapter }
onChange={ handleOnChange }
{ ...adaptiveFormProps }
/>
);
Expand Down
21 changes: 21 additions & 0 deletions js/src/components/paid-ads/campaign-assets-form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ import userEvent from '@testing-library/user-event';
*/
import CampaignAssetsForm from './campaign-assets-form';

jest.mock( '@woocommerce/settings', () => ( {
getSetting: jest
.fn()
.mockName( "getSetting( 'currency' )" )
.mockReturnValue( {
code: 'EUR',
symbol: '€',
precision: 2,
decimalSeparator: '.',
thousandSeparator: ',',
priceFormat: '%1$s %2$s',
} ),
} ) );

jest.mock( '.~/hooks/useFetchBudgetRecommendation', () => ( {
__esModule: true,
default: jest.fn().mockImplementation( () => {
return [ jest.fn(), null ];
} ),
} ) );

const alwaysValid = () => ( {} );

describe( 'CampaignAssetsForm', () => {
Expand Down
41 changes: 35 additions & 6 deletions js/src/components/paid-ads/validateCampaign.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,55 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { __, sprintf } from '@wordpress/i18n';

/**
* @typedef {import('.~/components/types.js').CampaignFormValues} CampaignFormValues
* @typedef {import('.~/components/types.js').ValidateCampaignOptions} ValidateCampaignOptions
*/

// Minimum percentage of the recommended daily budget.
const BUDGET_MIN_PERCENT = 0.3;

/**
* Validate campaign form. Accepts the form values object and returns errors object.
*
* @param {CampaignFormValues} values Campaign form values.
* @param {ValidateCampaignOptions} opts Extra form options.
* @return {Object} errors.
*/
const validateCampaign = ( values ) => {
const validateCampaign = ( values, opts ) => {
const errors = {};

if ( ! Number.isFinite( values.amount ) || values.amount <= 0 ) {
errors.amount = __(
'Please make sure daily average cost is greater than 0.',
'google-listings-and-ads'
);
return {
amount: __(
'Please make sure daily average cost is greater than 0.',
'google-listings-and-ads'
),
};
}
eason9487 marked this conversation as resolved.
Show resolved Hide resolved

if (
asvinb marked this conversation as resolved.
Show resolved Hide resolved
Number.isFinite( values?.amount ) &&
Number.isFinite( opts?.dailyBudget )
) {
const { amount } = values;
const { dailyBudget, formatAmount } = opts;
const minAmount = Math.round( dailyBudget * BUDGET_MIN_PERCENT, 2 );
eason9487 marked this conversation as resolved.
Show resolved Hide resolved

if ( amount < minAmount ) {
return {
amount: sprintf(
/* translators: %1$s: minimum daily budget */
__(
'Please make sure daily average cost is greater than %s.',
'google-listings-and-ads'
),
formatAmount( minAmount )
),
};
}
}

return errors;
Expand Down
28 changes: 28 additions & 0 deletions js/src/components/paid-ads/validateCampaign.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,32 @@ describe( 'validateCampaign', () => {
expect( errors ).toHaveProperty( 'amount' );
expect( errors.amount ).toMatchSnapshot();
} );

it( 'When a budget is provided and the amount is less than the minimum, should not pass', () => {
const mockFormatAmount = jest.fn().mockReturnValue( 'Rs 30' );
values.amount = 10;

const opts = {
dailyBudget: 100,
formatAmount: mockFormatAmount,
};

const errors = validateCampaign( values, opts );

expect( errors ).toHaveProperty( 'amount' );
expect( errors.amount ).toContain( 'is greater than Rs 30' );
} );

it( 'When a budget is provided and the amount is greater than the minimum, should pass', () => {
const mockFormatAmount = jest.fn().mockReturnValue( 'Rs 35' );
values.amount = 35;

const opts = {
dailyBudget: 100,
formatAmount: mockFormatAmount,
};

const errors = validateCampaign( values, opts );
expect( errors ).not.toHaveProperty( 'amount' );
} );
} );
6 changes: 6 additions & 0 deletions js/src/components/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
* @property {number} amount The daily average cost amount.
*/

/**
* @typedef {Object} ValidateCampaignOptions
* @property {string} Currency symbol to be used (e.g., 'USD').
* @property {number} dailyBudget Daily budget for the campaign.
eason9487 marked this conversation as resolved.
Show resolved Hide resolved
*/

/**
* @typedef {Object} AssetGroupFormValues
* @property {string | null} final_url - The page URL on merchant's website that people reach when they click the ad.
Expand Down
1 change: 1 addition & 0 deletions js/src/data/action-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const TYPES = {
UPSERT_TOUR: 'UPSERT_TOUR',
HYDRATE_PREFETCHED_DATA: 'HYDRATE_PREFETCHED_DATA',
RECEIVE_GOOGLE_ADS_ACCOUNT_STATUS: 'RECEIVE_GOOGLE_ADS_ACCOUNT_STATUS',
RECEIVE_ADS_BUDGET_RECOMMENDATIONS: 'RECEIVE_ADS_BUDGET_RECOMMENDATIONS',
};

export default TYPES;
25 changes: 25 additions & 0 deletions js/src/data/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1287,3 +1287,28 @@
);
}
}

/**
* Action to receive ad budget recommendations.
*
* This function constructs an action object containing the type of the action,
* the provided country codes, currency, and recommendations.
*
* @param {string} countryCodesKey String identifying the country codes.
* @param {string} currency The currency code representing the currency of the recommendations.
* @param {Object} recommendations The budget recommendations object.
eason9487 marked this conversation as resolved.
Show resolved Hide resolved
* @param {CountryCode} recommendations.country The country-specific recommendations.
* @param {number} recommendations.daily_budget The daily budget recommendation for the country.
*/
export function* receiveAdsBudgetRecommendations(
eason9487 marked this conversation as resolved.
Show resolved Hide resolved
countryCodesKey,
currency,
recommendations
) {
return {

Check warning on line 1308 in js/src/data/actions.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/actions.js#L1307-L1308

Added lines #L1307 - L1308 were not covered by tests
type: TYPES.RECEIVE_ADS_BUDGET_RECOMMENDATIONS,
countryCodesKey,
currency,
recommendations,
};
}
14 changes: 14 additions & 0 deletions js/src/data/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
inviteLink: null,
step: null,
},
budgetRecommendations: {},
},
};

Expand Down Expand Up @@ -510,6 +511,19 @@
.end();
}

case TYPES.RECEIVE_ADS_BUDGET_RECOMMENDATIONS: {
const { countryCodesKey, currency, recommendations } = action;

Check warning on line 515 in js/src/data/reducer.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/reducer.js#L514-L515

Added lines #L514 - L515 were not covered by tests

return setIn(

Check warning on line 517 in js/src/data/reducer.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/reducer.js#L517

Added line #L517 was not covered by tests
state,
[ 'ads', 'budgetRecommendations', countryCodesKey ],
{
currency,
recommendations,
}
);
}

// Page will be reloaded after all accounts have been disconnected, so no need to mutate state.
case TYPES.DISCONNECT_ACCOUNTS_ALL:
default:
Expand Down
53 changes: 52 additions & 1 deletion js/src/data/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
} from '.~/constants';
import TYPES from './action-types';
import { API_NAMESPACE } from './constants';
import { getReportKey } from './utils';
import { getReportKey, getCountryCodesKey } from './utils';
import { handleApiError } from '.~/utils/handleError';
import { adaptAdsCampaign, adaptAssetGroup } from './adapters';
import { fetchWithHeaders, awaitPromise } from './controls';
Expand Down Expand Up @@ -46,8 +46,15 @@
receiveMappingRules,
receiveStoreCategories,
receiveTour,
receiveAdsBudgetRecommendations,
} from './actions';

/**
* CountryCode
*
* @typedef {string} CountryCode Two-letter country code in ISO 3166-1 alpha-2 format. Example: 'US'.
*/
eason9487 marked this conversation as resolved.
Show resolved Hide resolved

export function* getShippingRates() {
yield fetchShippingRates();
}
Expand Down Expand Up @@ -534,3 +541,47 @@
getGoogleAdsAccountStatus.shouldInvalidate = ( action ) => {
return action.type === TYPES.DISCONNECT_ACCOUNTS_GOOGLE_ADS;
};

/**
* Fetch ad budget recommendations for the specified country codes.
*
* @param {Array<CountryCode>} countryCodes An array of country codes for which to fetch budget recommendations.
eason9487 marked this conversation as resolved.
Show resolved Hide resolved
*/
export function* getAdsBudgetRecommendations( countryCodes ) {

Check warning on line 550 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L550

Added line #L550 was not covered by tests
if ( ! countryCodes || ! countryCodes.length ) {
return;

Check warning on line 552 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L552

Added line #L552 was not covered by tests
}

const countryCodesKey = getCountryCodesKey( countryCodes );
const endpoint = `${ API_NAMESPACE }/ads/campaigns/budget-recommendation`;
const query = { country_codes: countryCodes };
const path = addQueryArgs( endpoint, query );

Check warning on line 558 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L555-L558

Added lines #L555 - L558 were not covered by tests

try {
const { data } = yield fetchWithHeaders( {

Check warning on line 561 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L560-L561

Added lines #L560 - L561 were not covered by tests
path,
} );

yield receiveAdsBudgetRecommendations(

Check warning on line 565 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L565

Added line #L565 was not covered by tests
countryCodesKey,
data.currency,
data.recommendations
);
} catch ( response ) {
// Intentionally silence the specific in case the no budget recommendations are found from the API.
if ( response.status === 404 ) {
return;

Check warning on line 573 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L573

Added line #L573 was not covered by tests
}

const bodyPromise = response?.json() || response?.text();
const error = yield awaitPromise( bodyPromise );

Check warning on line 577 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L577

Added line #L577 was not covered by tests

handleApiError(

Check warning on line 579 in js/src/data/resolvers.js

View check run for this annotation

Codecov / codecov/patch

js/src/data/resolvers.js#L579

Added line #L579 was not covered by tests
error,
__(
'There was an error getting the budget recommendation.',
'google-listings-and-ads'
)
);
}
}
Loading
Loading