-
Notifications
You must be signed in to change notification settings - Fork 398
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
Convert add-on detail page to use sagas #2602
Conversation
@tofumatt for some reason the |
Whoops, I forgot to handle 404s. This is not ready for a review yet. |
@tofumatt 404s are implemented now. It's ready for review. |
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've left some comments and questions but largely this looks great.
I'm gonna check it out locally and see about the loading indicator.
I think other than some nits or little improvements my main worry is with the addon component not dispatching a new viewContext when it's updated without being remounted. eg If you were on a theme and you linked to an extension, setViewContext
wouldn't be dispatched because the component had already mounted and the router would simply updated its props.
The LandingPage component handles this kind of scenario and I think all of our components will need to do this–if we don't then components that dispatch on update won't work as expected when they link to themselves, if that makes sense.
https://github.com/mozilla/addons-frontend/blob/master/tests/unit/amo/components/TestLandingPage.js#L47 is an example of testing it from the LandingPage
.
I'll have a look through and make suggestions on LoadingText and such tonight or tomorrow.
import InstallButton from 'core/components/InstallButton'; | ||
import { ADDON_TYPE_THEME, ENABLED, UNKNOWN } from 'core/constants'; | ||
import { | ||
ADDON_TYPE_EXTENSION, ADDON_TYPE_THEME, ENABLED, UNKNOWN, |
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.
Nit but once we start doing more imports than fit on a line it's one-per-line eg:
import {
ADDON_TYPE_EXTENSION,
ADDON_TYPE_THEME,
ENABLED,
UNKNOWN,
} from 'core/constants';
Though I really wish we could have a rule for that...
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.
Nit but once we start doing more imports than fit on a line it's one-per-line
it's pretty easy to fix once they can't fit on a line anymore
@@ -73,9 +80,13 @@ export class AddonBase extends React.Component { | |||
} | |||
|
|||
componentWillMount() { |
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 notice there's nothing here for componentDidUpdate
–if an add-on linked to another add-on the components on the page wouldn't change but the props would update and we would want to do things like update the viewContext
.
Of course, it would be nice if we did that more often with routing–as in this case we could get away with using the URL to know addonType
but for now we should dispatch(setViewContext())
on componentDidUpdate
.
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.
This is fixed now
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.
Thanks; sorry it was an awkward place to leave a comment as I know it won't be cleared. 😅
endSpan: '</span>', | ||
}); | ||
let errorBanner = null; | ||
if (errorHandler.hasError()) { |
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 haven't seen this HOC used much but that's really nice in usage. 👍
src/amo/components/Addon/index.js
Outdated
@@ -289,9 +361,13 @@ export class AddonBase extends React.Component { | |||
} | |||
|
|||
export function mapStateToProps(state, ownProps) { | |||
// TODO: make this function handle a falsy addon |
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.
Seems like this checks for a falsy add-on below–is this TODO still needed?
import { getApi } from './utils'; | ||
|
||
|
||
export function* fetchAddon( |
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 only commenting here to say this code is very easy to follow and is making me happy we're moving to sagas. It makes our async code feel a bit more idiomatic too, compared to the other stuff which was a bit less consistently structured. 👍
@@ -1,4 +1,5 @@ | |||
/* global window */ | |||
import { shallow } from 'enzyme'; |
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.
🎉
fakeDispatch, fetchAddonAction({ errorHandler, slug: slugParam })); | ||
|
||
// These should be empty: | ||
expect(root.find(InstallButton)).toHaveLength(0); |
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.
Oh cool, I didn't know you could use components; I've always been using selectors. That's awesome.
|
||
// These should be empty: | ||
expect(root.find(InstallButton)).toHaveLength(0); | ||
expect(root.find(AddonCompatibilityError)).toHaveLength(0); |
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.
Not sure if https://github.com/blainekasten/enzyme-matchers#tobeempty is a better fit for these tests, but it's up to you. I think I've written tests with expect(root.find('.selector')).toHaveLength(0);
before and I'm fine with it.
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 do like that toHaveLength(0)
and toHaveLength(1)
contrast well with each other in this test though, so that's a reason to keep using it over the toBeEmpty()
matcher.
|
||
it('renders 404 page for missing add-on', () => { | ||
const id = 'error-handler-id'; | ||
const store = createStore().store; |
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.
Could use const { store } = createStore();
here... and I'd argue we should try to just use dispatchClientMetadata()
over raw createStore()
wherever possible because it loads data we can use if we ever have to and is just as easy to use.
But up to you on the usage of createStore()
vs the dispatch helpers. Obviously these tests are mostly not dispatching things so it doesn't really matter.
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.
Just added a few more comments after running it locally. Looks like there are style regressions for both extension and theme detail pages; if you compare them to master you'll see things aren't quite right. I didn't yet explore why but give me a shout if it's not obvious and I can track it down.
Once the styles are back in sync let me know if LoadingText is still an issue.
One recommendation, kind of unrelated to this patch, is to change <LoadingText />
to output a <span>
rather than a <div>
, as I saw some complaints in the console if we put <LoadingText />
inside a <p>
(react doesn't like putting a DIV in a P tag). So changing it might be better... I can just do that in a separate PR though if you'd rather :-)
src/amo/components/Addon/index.js
Outdated
const summary = addon.summary ? addon.summary : addon.description; | ||
summaryProps.dangerouslySetInnerHTML = sanitizeHTML(summary, ['a']); | ||
} else { | ||
summaryPlaceholder = <LoadingText />; |
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 someone thinks they're clever and tries to remove this else block like I did locally, and just set the placeholder, we'll get an invariant error because React won't allow .children
and .dangerousSetInnerHTML
. I think it might be nice to just remove the placeholder variable and set summaryProps.children = <LoadingText />
here.
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.
Whoa, using .children
makes everything way easier!
// Themes lack a summary so we do the inverse :-/ | ||
// TODO: We should file an API bug about this... | ||
const summary = addon.summary ? addon.summary : addon.description; | ||
summaryProps.dangerouslySetInnerHTML = sanitizeHTML(summary, ['a']); |
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 we should remove the /* eslint-disable react/no-danger */
at the top of the file and around the render()
method–instead we should just use // eslint-disable-next-line react/no-danger
, as it's a good thing to call out and recognise every time we use it 😄
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.
actually, we don't even need it because the linter isn't smart enough to follow the summaryProps
object 😆
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.
Oh no! 😆
src/amo/components/Addon/index.js
Outdated
); | ||
titleProps.dangerouslySetInnerHTML = sanitizeHTML(title, ['a', 'span']); | ||
} else { | ||
titlePlaceholder = <LoadingText />; |
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 tried locally and I think this would benefit from a higher minWidth
which is a property on <LoadingText />
– it sets the minimum % the text block will fill. As titles aren't usually a few letters something like 70% or even 100% might work better for a lot of these short fields.
src/amo/components/Addon/index.js
Outdated
<h2 className="visually-hidden"> | ||
{i18n.gettext('Extension Metadata')} | ||
</h2> | ||
{addon ? <AddonMeta addon={addon} /> : <LoadingText />} |
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 would benefit from AddonMeta
being able to handle an empty addon
and rendering each section as <LoadingText />
instead of just one bar, eg:
if (!addon) {
return (
<div className="AddonMeta">
<div className="AddonMeta-item AddonMeta-users">
<h3 className="visually-hidden"><LoadingText /></h3>
<p className="AddonMeta-text"><LoadingText /></p>
<p className="AddonMeta-text AddonMeta-review-count">
<LoadingText />
</p>
<Rating className="AddonMeta-Rating" rating={0} readOnly
styleName="small" />
</div>
</div>
);
}
Though probably better implemented in the component like you did in Addon
so we aren't duplicating the HTML.
{this.renderRatingsCard()} | ||
|
||
<AddonMoreInfo addon={addon} /> | ||
{addon ? <AddonMoreInfo addon={addon} /> : null} |
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.
Same here as for AddonMeta
; handling an empty addon
would be good and we should render this component with the LoadingText. That way fewer elements appear out of nowhere once the add-on loads and the page looks less empty whilst loading.
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.
AddonMoreInfo
was pretty tough to convert. I don't think it's worth the effort right now.
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.
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 'No reviews yet' is fixed.
As for AddonMoreInfo
, I don't think it's worth spending time on but, sure, I filed and marked it as contrib: welcome
https://github.com/mozilla/addons-frontend/issues/2629
@tofumatt I fixed the style regressions, thanks for catching that. It should be ready for another look now. |
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.
🎉
A few changes requested but I don't think any of them should require another review and I know you want to get other stuff through after this.
I tested it locally and it feels great. Really nice experience going from the search results.
r+wc
|
||
dispatch(setViewContext(addon.type)); | ||
componentWillReceiveProps({ addon: newAddon }) { | ||
const { addon: oldAddon, dispatch } = this.props; |
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.
Woooah I've never seen this syntax! I'm guessing it's the same as:
const addon = this.props.oldAddon;
const dispatch = this.props.dispatch;
Neat-o, just didn't know about it.
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 don't think it's the greatest syntax. It's actually a shortcut for this which is different from the code you posted:
const oldAddon = this.props.addon;
const dispatch = this.props.dispatch;
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.
Ohh, wow. That's not what I expect, making it a bit hard to read. Maybe it would be nicer to do:
const { dispatch } = this.props;
const addon = this.props.oldAddon;
I just find that syntax opposite to what I expect.
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 don't think we should add workarounds for standard JavaScript syntax. It is what it is and it's well documented. It will be around for a long time :) We can help teach contributors if they get stuck with it.
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.
Plus, your workaround suggestion adds an extra line of code -- less code is always easier to read IMO.
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.
Fair enough!
I don't think it's easier to read, but it is a standard syntax so at least someone can look it up. I think there are language features we don't use because they aren't pleasant and I'd nominate this one as well... I think it's easier to reason about what this is doing with the two lines of code, and it's not logic just assignment so I think it doesn't add complexity.
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.
Once I got used to it it no longer seemed foreign. I really wish it looked more like this but oh well:
const { addon as oldAddon } = this.props;
^ This is how I read it in my head now which helps me parse it.
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.
omg, agreed. Why didn't they just do that?!
That actually helps though, thanks 👍
version={addon.current_version} | ||
/> | ||
{addon ? | ||
<RatingManager |
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 should be done in another patch, I guess the Reviews one maybe but it would be great if this showed LoadingText in place of "No reviews yet" as well. Anyway that's just a note/UX thing to keep in mind, this patch is big enough now and no need to introduce more complexity.
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.
Actually, yeah, I was thinking about this too. The 'No reviews yet' is an answer to a question asked, which is 'how many reviews are there for this add-on?' In this case, it is not the right answer to give. I'll see if I can change it without much churn.
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 commented below–it looks like it should be straightforward.
But if it becomes a beast I'm happy to wait for another patch.
{this.renderRatingsCard()} | ||
|
||
<AddonMoreInfo addon={addon} /> | ||
{addon ? <AddonMoreInfo addon={addon} /> : null} |
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.
src/amo/components/AddonMeta.js
Outdated
const averageRating = addon.ratings.average; | ||
const addonRatingCount = addon.ratings.count; | ||
const averageRating = addon ? addon.ratings.average : null; | ||
const addonRatingCount = addon ? addon.ratings.count : false; |
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.
This is hyper-semantic but is the count
being set to false
really correct? It's more null
than false
. To me null
signifies we don't have any info yet while 0
signifies a zero count. false
feels different than either of those things and more deliberate.
src/amo/components/AddonMeta.js
Outdated
{ total: i18n.formatNumber(averageDailyUsers) }, | ||
); | ||
} else { | ||
userCount = <LoadingText />; |
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.
const fakeDispatch = sinon.stub(); | ||
const root = render({ dispatch: fakeDispatch }); | ||
fakeDispatch.reset(); | ||
root.componentWillReceiveProps({ |
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 enzyme I think you can even do root.setProps({ addon: { [...] } })
and it will trigger componentWillReceiveProps
... might be a bit more natural than calling it directly and it simulates real behaviour a bit more.
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.
oops, I forgot to convert the rest of this suite to use Enzyme. Heading down the rabbit hole, brb! 😓
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.
Cool, let me know if you think it warrants another review; should be easy to do and quick!
const fakeDispatch = sinon.stub(); | ||
const root = render({ addon: fakeAddon, dispatch: fakeDispatch }); | ||
fakeDispatch.reset(); | ||
// Update with the same addon (this apparently happens IRL). |
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.
Can you change IRL
to in usage
? The abbreviation might be tough for an ESL speaker.
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: it wouldn't be the same add-on, it would be a different one but the component would stay "mounted" just receiving entirely new props. So if you had a theme that linked to an extension, the props would update but the component would not call componentWillMount
.
So really this is simulating "update the component with a new add-on of a different type".
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.
Surprisingly, it does update with the same add-on for some reason. I discovered this because the page got stuck in an infinite loop and crashed Firefox.
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.
Ah, weird. I think componentDidUpdate
is only called if the props changed whereas componentWillUpdate
is called either way maybe? My bad if I suggested the wrong one. We should double-check we're using the right one according to the lifecycle docs: https://facebook.github.io/react/docs/react-component.html#the-component-lifecycle
const fakeDispatch = sinon.stub(); | ||
const root = render({ dispatch: fakeDispatch }); | ||
fakeDispatch.reset(); | ||
root.componentWillReceiveProps({}); |
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 can use root.setProps({})
as well.
@@ -0,0 +1,24 @@ | |||
import { shallow } from 'enzyme'; |
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.
Damn, did this not have any tests before? I'm sorry 😳
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.
Oh, it did. This test file was just never renamed after the component changed names (all I did here was rename it).
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.
Oh, cool! Thanks!
|
||
it('lets you set a fixed width', () => { | ||
const root = render({ width: 55 }); | ||
expect(root.prop('style')).toMatchObject({ width: '55%' }); |
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.
This can be expect(root).toHaveProp('style', { width: '55%' });
because I don't think we expect the component to be setting style props other than width.
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.
But if you want to leave it as is I suppose it's more extensible. Up to you.
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 helps for maintenance to keep assertions isolated to exactly what they are testing. In this case, the test is only checking for the width style rule. It should be flexible enough to allow new style rules in the future without the test breaking.
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.
Cool. I do really like toMatchObject
for that 👍
Thanks for all the reviews. |
Fixes mozilla/addons#10478
Here is what it looks like before the add-on has loaded: