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

Focused Launch: Fix billing period tracking in summary #49930

Merged
merged 15 commits into from
Feb 18, 2021

Conversation

alshakero
Copy link
Member

@alshakero alshakero commented Feb 10, 2021

Changes proposed in this Pull Request

  • I added getLastPlanBillingPeriod selector to the plan store to get the billing period of the current or last selected paid plan's period.
  • I added a private (i.e unexported) action creator __setBillingPeriod to the launch store. And I changed the setPlanProductId action creator into a generator that queries the plans store for the plans billing period using its productId, then it dispatches the billing period using the newly-added __setBillingPeriod and the reducer records it. The makes the plan readonly and can only be changed by changing the plan.
  • I updated the usePlans hook to return the plan products, too.
  • I refactored summary view logic around plans and plan products.

Testing instructions

  1. Create or reuse a site that is created with /start flow.
  2. Sandbox this site.
  3. In apps/editing-toolkit run yarn dev --sync.
  4. Go to https://[yoursite].wordpress.com/wp-admin/post-new.php
  5. Click launch.
  6. Go to detailed plan page, pick a monthly plan.
  7. In summary, now pick a free plan, the prices should remain monthly.
  8. Pick a non-default paid plan, it should be rendered in summary, re-pick the default paid plan (Premium), the non-default plan should remain listed.

Fixes #49713

@alshakero alshakero added [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. Focused Launch Issues and PRs related to Focused Launch labels Feb 10, 2021
@alshakero alshakero self-assigned this Feb 10, 2021
@matticbot
Copy link
Contributor

@matticbot
Copy link
Contributor

matticbot commented Feb 10, 2021

Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

App Entrypoints (~2 bytes added 📈 [gzipped])

name                 parsed_size           gzip_size
entry-gutenboarding        +34 B  (+0.0%)       +2 B  (+0.0%)

Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used.

Legend

What is parsed and gzip size?

Parsed Size: Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
Gzip Size: Compressed size of the JS and CSS files. This much data needs to be downloaded over network.

Generated by performance advisor bot at iscalypsofastyet.com.

@razvanpapadopol razvanpapadopol self-requested a review February 11, 2021 09:49
@ciampo ciampo self-requested a review February 11, 2021 10:37
Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

The launch store had a selector that returns the current paid plan. While it is meant to return the current or once-picked paid plan. I fixed that selector.

I wasn't really aware of how that selector was meant to work until I read your explanation.

As a user of the Launch data-store, when seeing a selector called getPaidPlanProductId(), I would expect the result of that selector to be undefined if there isn't a selected paid plan.

The fact that the result is assigned to a variable named selectedPaidPlanProductId would make me even more confused. If the user selects the free plan, why would a variable called selectedPaidPlanProductId still hold the value of a paid plan that is not currently selected?


To bring clarity, I think we should rename a few variables (sorry for the added verbosity, but it would make the code much easier to understand):

  • the reducer could be called lastSelectedPaidPlanProductId
  • the selector could be called getLastSelectedPaidPlanProductId()
  • this variable could be called lastSelectedPaidPlanProductId
  • this variable could be called lastSelectedPaidPlan

Finally, I believe that we should refactor this logic too, because now selectedPaidPlan can be defined even when the user hasn't currently selected a paid plan (we could use selectedPlan instead, with an additional check to see it's a free plan or a paid plan?)

@alshakero alshakero changed the title Launch store: fix launch store paid plan selector Focused Launch: Fix billing period tracking in summary Feb 11, 2021
@alshakero
Copy link
Member Author

@ciampo I agree it was pretty confusing. I removed the reducer, the selector, and moved the logic to the focused launch.

Copy link
Contributor

@StefanNieuwenhuis StefanNieuwenhuis left a comment

Choose a reason for hiding this comment

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

The behavior looks good to me (see https://www.loom.com/share/f637c03af41d4b14abf78f1375afcfa6) and can you address my comment, please?

Comment on lines 29 to 38
const defaultFreePlan = select( PLANS_STORE ).getDefaultFreePlan( locale );
const defaultPaidPlan = select( PLANS_STORE ).getDefaultPaidPlan( locale );
const defaultPaidPlanProduct = select( PLANS_STORE ).getPlanProduct(
defaultPaidPlan?.periodAgnosticSlug,
billingPeriod
);
const defaultFreePlanProduct = select( PLANS_STORE ).getPlanProduct(
defaultFreePlan?.periodAgnosticSlug,
billingPeriod
);
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is a bit cleaner and easier to maintain. What do you think?

Suggested change
const defaultFreePlan = select( PLANS_STORE ).getDefaultFreePlan( locale );
const defaultPaidPlan = select( PLANS_STORE ).getDefaultPaidPlan( locale );
const defaultPaidPlanProduct = select( PLANS_STORE ).getPlanProduct(
defaultPaidPlan?.periodAgnosticSlug,
billingPeriod
);
const defaultFreePlanProduct = select( PLANS_STORE ).getPlanProduct(
defaultFreePlan?.periodAgnosticSlug,
billingPeriod
);
const plansStore = select(PLANS_STORE);
const defaultFreePlan = plansStore.getDefaultFreePlan( locale );
const defaultPaidPlan = plansStore.getDefaultPaidPlan( locale );
const defaultPaidPlanProduct = plansStore.getPlanProduct(
defaultPaidPlan?.periodAgnosticSlug,
billingPeriod
);
const defaultFreePlanProduct = plansStore.getPlanProduct(
defaultFreePlan?.periodAgnosticSlug,
billingPeriod
);

Choose a reason for hiding this comment

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

Also, don't forget to add the second argument to useSelect function which in this case should be [locale, billingPeriod]. See p7H4VZ-2Q6-p2#comment-9214

@@ -32,13 +32,12 @@ import FocusedLaunchSummaryItem, {
LeadingContentSide,
TrailingContentSide,
} from './focused-launch-summary-item';
import { LAUNCH_STORE, SITE_STORE, Plan } from '../../stores';
import { LAUNCH_STORE, SITE_STORE, Plan, PlanProduct, PLANS_STORE } from '../../stores';

Choose a reason for hiding this comment

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

I think it's also fine to add on another line: import type { Plan, PlanProduct } from '../../stores';


return productId && ! isFree ? state.planProductId : undefined;
};

Choose a reason for hiding this comment

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

I agree we don't need this selector as it is but if you look at changes from #48452 we are using twice this selector but just to check if the selected plan is a paid plan so isSelectedPlanPaid might be a good candidate for a selector on Launch store.

@alshakero
Copy link
Member Author

Thanks everyone! I addressed all feedback.

@ciampo ciampo self-requested a review February 15, 2021 16:01
@StefanNieuwenhuis StefanNieuwenhuis self-requested a review February 15, 2021 16:02
Copy link
Contributor

@StefanNieuwenhuis StefanNieuwenhuis left a comment

Choose a reason for hiding this comment

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

LGTM! 🚢

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

I left a few comments, although the changes seem to work well!


I also wanted to raise one more edge case that I came across while testing:

  1. Open focused launch modal
  2. In summary view, select the free subdomain
  3. Go to Plan details view, and pick a monthly billed plan
  4. Go back to Summary view — everything looks as expected
  5. Select the free plan in the Summary view — the Premium plan stays unchanged (monthly billed), even if it's not selected anymore
  6. Go to Plan details and click the "Back" button, back to Summary view
  7. The Free plan is selected, but now the plan above it is the annually billed premium plan (and not the monthly)
focused-launch-summar-plans-billing-preiod.mp4

To me this is behaviour is acceptable (plus, it's very rare edge case), but I just wanted to document it in my review notes.

packages/data-stores/src/launch/selectors.ts Outdated Show resolved Hide resolved
packages/launch/src/focused-launch/summary/index.tsx Outdated Show resolved Hide resolved
Comment on lines +341 to +337
const allAvailablePlansProducts: ( PlanProduct | undefined )[] = nonDefaultPaidPlanProduct
? [ defaultPaidPlanProduct, nonDefaultPaidPlanProduct, defaultFreePlanProduct ]
: [ defaultPaidPlanProduct, defaultFreePlanProduct ];
Copy link
Contributor

@ciampo ciampo Feb 15, 2021

Choose a reason for hiding this comment

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

With these changes, the allAvaiablePlans and allAvailablePlanProducts arrays can technically go "out of sync".

We should think about a way of making sure that this doesn't happen — and so I though that one potential solution is to add one more check to https://github.com/Automattic/wp-calypso/blob/77f73e53b72bd1d5e074a0c61f1d8b7953807e21/packages/launch/src/focused-launch/summary/index.tsx#L422-L423

and change it to:

typeof plan === 'undefined' ||
typeof allAvailablePlansProducts?.[ index ] === 'undefined' ||
plan.periodAgnosticSlug !== allAvailablePlansProducts?.[ index ]?.periodAgnosticSlug

Basically, the third check makes sure that the plan and the planProduct are in sync and refer to the same plan.

What do you think?

Copy link
Member Author

@alshakero alshakero Feb 17, 2021

Choose a reason for hiding this comment

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

I think they will never be out of sync. Two factors determine their order:

  1. These two synchronous lines. Because they control the ternary.
  2. These lines that use the same resolver that won't resolve until all is fetched.

Nonetheless, I cleaned up that code a bit.

@ciampo
Copy link
Contributor

ciampo commented Feb 16, 2021

Adding the "Hold" label because, as discussed in p1613476301450800/1613380064.426100-slack-C0KDTA48Y, we should wait for #48452 to be merged first, rebase, and then act on the points suggested by this and this comment

@alshakero alshakero force-pushed the fix/keep-track-of-paid-plan branch from 969db9c to bc18130 Compare February 17, 2021 20:04
Comment on lines 157 to 166
type SetPlanActionParams = () =>
| {
readonly type: 'SET_PLAN_BILLING_PERIOD';
readonly billingPeriod: Plans.PlanBillingPeriod;
}
| {
readonly type: 'SET_PLAN_PRODUCT_ID';
readonly planProductId: number | undefined;
};

Copy link
Member Author

Choose a reason for hiding this comment

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

@sirreal Hi Jon! I couldn't figure out a way to type a generator action creator. Do you have a more elegant solution? This one isn't quite maintainable.

Copy link
Member

@sirreal sirreal Feb 17, 2021

Choose a reason for hiding this comment

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

How about swapping ReturnType below for a new mapped type helper:

type ReturnOrGeneratorYieldUnion<T extends (...args: any) => any> = T extends (
	...args: any
) => infer Return
	? (Return extends Generator<infer T, infer U, any>
		? T | U
		: Return)
	: never;

export type LuanchAction = ReturnOrGeneratorYieldUnion<
  | typeof setPlanProductId
  | typeof setSiteTitle /* … */
>

I think you'll get the result you're looking for. Here's a playground link.

Copy link
Member Author

@alshakero alshakero Feb 18, 2021

Choose a reason for hiding this comment

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

Thank you so much! We could add this type to wp.data pacakge. Since it will be needed whenever someone needs a generator action.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm undecided between that above and this simpler solution:

export type LaunchAction =
	| ReturnType<
			| typeof setSiteTitle
			.
			.
			| typeof hideModalTitle
	  >
	| ReturnType< ReturnType< typeof setPlanProductId >[ 'next' ] >[ 'value' ]; // <--- this

Copy link
Member

Choose a reason for hiding this comment

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

That's simpler here for a one-off case. I'd like to see a library-level solution for this that we could life to Gutenberg when it's ready like you suggest.

Comment on lines +156 to +167
/**
* Usually we use ReturnType of all the action creators to deduce all the actions.
* This works until one of the action creators is a generator and doesn't actually "Return" an action.
* This type helper allows for actions to be both functions and generators
*/
type ReturnOrGeneratorYieldUnion< T extends ( ...args: any ) => any > = T extends (
...args: any
) => infer Return
? Return extends Generator< infer T, infer U, any >
? T | U
: Return
: never;
Copy link
Member

Choose a reason for hiding this comment

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

If this better describes action creators, we should move it with other helpers and use it in other store action creators as well.

/**
* Maps a "raw" actionCreators object to the actions available when registered on the @wordpress/data store.
*
* @template A Selector map, usually from `import * as actions from './my-store/actions';`
*/
export type DispatchFromMap< A extends Record< string, ( ...args: any[] ) => any > > = {
[ actionCreator in keyof A ]: (
...args: Parameters< A[ actionCreator ] >
) => A[ actionCreator ] extends ( ...args: any[] ) => Generator
? Promise< GeneratorReturnType< A[ actionCreator ] > >
: void;
};
/**
* Parameters type of a function, excluding the first parameter.
*
* This is useful for typing some @wordpres/data functions that make a leading
* `state` argument implicit.
*/
export type TailParameters< F extends Function > = F extends ( head: any, ...tail: infer T ) => any
? T
: never;
/**
* Obtain the type finally returned by the generator when it's done iterating.
*/
export type GeneratorReturnType< T extends ( ...args: any[] ) => Generator > = T extends (
...args: any
) => Generator< any, infer R, any >
? R
: never;

This is the type of thing wp-data should handle in its type system and I think a lot of the utilities we've developed here can move there when the library is typed. This is the kind of thing the system should take care of if we get it right and we shouldn't have to deal with them to build a soundly typed store 🙂

@@ -52,11 +55,27 @@ export const setDomainSearch = ( domainSearch: string ) =>
domainSearch,
} as const );

export const setPlanProductId = ( planProductId: number | undefined ) =>
const __setBillingPeriod = ( billingPeriod: Plans.PlanBillingPeriod ) =>
Copy link
Contributor

@ciampo ciampo Feb 18, 2021

Choose a reason for hiding this comment

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

No need of prefixing with __, as this function is not exported anyway (plus, we may actually need it when working on #49253 and/or #50186)

billingPeriod,
} as const );

export const setPlanProductId = function* setPlanProductId( planProductId: number | undefined ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

No need to repeat setPlanProductId twice. It can be rewritten to

Suggested change
export const setPlanProductId = function* setPlanProductId( planProductId: number | undefined ) {
export function* setPlanProductId( planProductId: number | undefined ) {

or, to keep consistency with the rest of the exported functions, to

Suggested change
export const setPlanProductId = function* setPlanProductId( planProductId: number | undefined ) {
export const setPlanProductId = function* ( planProductId: number | undefined ) {

@@ -38,6 +38,7 @@ export {
TIMELESS_PLAN_PREMIUM,
TIMELESS_PLAN_BUSINESS,
TIMELESS_PLAN_ECOMMERCE,
FREE_PLAN_PRODUCT_ID,
Copy link
Contributor

@ciampo ciampo Feb 18, 2021

Choose a reason for hiding this comment

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

Looks like this export does not exist in the file

Never mind this. Looks like my TypeScript server had a hiccup — all good :)

@ciampo ciampo self-requested a review February 18, 2021 14:08
Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

LGTM 🚢

(mobile e2e tests permitting)

@alshakero alshakero merged commit 8e78abe into trunk Feb 18, 2021
@alshakero alshakero deleted the fix/keep-track-of-paid-plan branch February 18, 2021 14:20
@matticbot matticbot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Feb 18, 2021
@ciampo ciampo mentioned this pull request Feb 18, 2021
20 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Focused Launch - Summary View: Prices jump from monthly to yearly after selecting a free plan
6 participants