-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Conversation
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser: App Entrypoints (~2 bytes added 📈 [gzipped])
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. Generated by performance advisor bot at iscalypsofastyet.com. |
There was a problem hiding this 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?)
@ciampo I agree it was pretty confusing. I removed the reducer, the selector, and moved the logic to the focused launch. |
There was a problem hiding this 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?
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 | ||
); |
There was a problem hiding this comment.
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?
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 | |
); |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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; | ||
}; | ||
|
There was a problem hiding this comment.
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.
Thanks everyone! I addressed all feedback. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM! 🚢
There was a problem hiding this 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:
- Open focused launch modal
- In summary view, select the free subdomain
- Go to Plan details view, and pick a monthly billed plan
- Go back to Summary view — everything looks as expected
- Select the free plan in the Summary view — the Premium plan stays unchanged (monthly billed), even if it's not selected anymore
- Go to Plan details and click the "Back" button, back to Summary view
- 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.
const allAvailablePlansProducts: ( PlanProduct | undefined )[] = nonDefaultPaidPlanProduct | ||
? [ defaultPaidPlanProduct, nonDefaultPaidPlanProduct, defaultFreePlanProduct ] | ||
: [ defaultPaidPlanProduct, defaultFreePlanProduct ]; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
- These two synchronous lines. Because they control the ternary.
- These lines that use the same resolver that won't resolve until all is fetched.
Nonetheless, I cleaned up that code a bit.
969db9c
to
bc18130
Compare
type SetPlanActionParams = () => | ||
| { | ||
readonly type: 'SET_PLAN_BILLING_PERIOD'; | ||
readonly billingPeriod: Plans.PlanBillingPeriod; | ||
} | ||
| { | ||
readonly type: 'SET_PLAN_PRODUCT_ID'; | ||
readonly planProductId: number | undefined; | ||
}; | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
/** | ||
* 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; |
There was a problem hiding this comment.
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.
wp-calypso/packages/data-stores/src/mapped-types.ts
Lines 31 to 61 in 61acc95
/** | |
* 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 ) => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
billingPeriod, | ||
} as const ); | ||
|
||
export const setPlanProductId = function* setPlanProductId( planProductId: number | undefined ) { |
There was a problem hiding this comment.
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
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
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, |
There was a problem hiding this comment.
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 :)
There was a problem hiding this 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)
Changes proposed in this Pull Request
getLastPlanBillingPeriod
selector to the plan store to get the billing period of the current or last selected paid plan's period.__setBillingPeriod
to the launch store. And I changed thesetPlanProductId
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.usePlans
hook to return the plan products, too.Testing instructions
/start
flow.apps/editing-toolkit
runyarn dev --sync
.Fixes #49713