This commit is contained in:
Kumar McMillan 2017-04-17 13:37:35 -05:00 коммит произвёл GitHub
Родитель e607750626
Коммит 3f41057886
32 изменённых файлов: 851 добавлений и 190 удалений

Просмотреть файл

@ -7,6 +7,7 @@
"plugins": [
"babel-plugin-dedent",
"transform-class-properties",
"transform-es2015-modules-commonjs"
"transform-es2015-modules-commonjs",
"transform-flow-strip-types"
]
}

Просмотреть файл

@ -55,7 +55,7 @@
}],
// This ensures imports are at the top of the file.
"import/imports-first": ["error"],
// This catches duplicate exports.
// This reports when you accidentally import/export an object twice.
"import/no-duplicates": ["error"],
// This ensures import statements never provide a file extension in the path.
"import/extensions": ["error", "never"],
@ -72,6 +72,8 @@
"import/newline-after-import": ["error"],
"jsx-a11y/no-static-element-interactions": "off",
"no-console": "error",
// We use import/no-duplicates instead because it supports Flow types.
"no-duplicate-imports": "off",
"no-plusplus": "off",
"no-underscore-dangle": "off",
"space-before-function-paren": ["error", "never"],

26
.flowconfig Normal file
Просмотреть файл

@ -0,0 +1,26 @@
[ignore]
# Ignore built/minified addons-frontend code.
<PROJECT_ROOT>/dist/.*
# These modules opt into Flow but we don't need to check them.
.*/node_modules/babel.*
.*/node_modules/react-nested-status
.*/node_modules/stylelint
[include]
[libs]
# TODO: this can go away after
# https://github.com/mozilla/addons-frontend/issues/2092
./flow/libs/dedent.js.flow
[options]
# This maps all Sass/SCSS imports to a dummy Flow file to suppress import
# errors. It's not necessary for Flow to analyze Sass/SCSS files.
module.name_mapper.extension='scss' -> '<PROJECT_ROOT>/flow/flowStub.js.flow'
module.system=node
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=./src
log.file=./flow/logs/flow.log
suppress_comment= \\(.\\|\n\\)*\\$FLOW_FIXME
suppress_comment= \\(.\\|\n\\)*\\$FLOW_IGNORE

1
.gitignore поставляемый
Просмотреть файл

@ -2,6 +2,7 @@
logs
*.log
npm-debug.log*
flow/logs/*log*
# Runtime data
pids

Просмотреть файл

@ -43,6 +43,8 @@ Generic scripts that don't need env vars. Use these for development:
| npm run dev:amo | Starts the dev server and proxy (amo) |
| npm run dev:amo:no-proxy| Starts the dev server without proxy (amo) |
| npm run dev:disco | Starts the dev server (discovery pane) |
| npm run flow:check | Check for Flow errors and exit |
| npm run flow:dev | Continuously check for Flow errors |
| npm run eslint | Lints the JS |
| npm run stylelint | Lints the SCSS |
| npm run lint | Runs all the JS + SCSS linters |
@ -84,6 +86,70 @@ or have `InfoDialog` in their behavior text.
Any option after the double dash (`--`) gets sent to `mocha`. Check out
[mocha's usage](https://mochajs.org/#usage) for ideas.
### Flow
There is limited support for using [Flow](https://flowtype.org/)
to check for problems in the source code.
To check for Flow issues during development while you edit files, run:
npm run flow:dev
If you are new to working with Flow, here are some tips:
* Check out the [getting started](https://flow.org/en/docs/getting-started/) guide.
* Read through the [web-ext guide](https://github.com/mozilla/web-ext/blob/master/CONTRIBUTING.md#check-for-flow-errors)
for hints on how to solve common Flow errors.
To add flow coverage to a source file, put a `/* @flow */` comment at the top.
The more source files you can opt into Flow, the better.
Here is our Flow manifesto:
* We use Flow to **declare the intention of our code** and help others
refactor it with confidence.
Flow also makes it easier to catch mistakes before spending hours in a debugger
trying to find out what happened.
* Avoid magic [Flow declarations](https://flowtype.org/en/docs/config/libs/)
for any *internal* code. Just declare a
[type alias](https://flowtype.org/en/docs/types/aliases/) next to the code
where it's used and
[export/import](https://flow.org/en/docs/types/modules/) it like any other object.
* Never import a real JS object just to reference its type. Make a type alias
and import that instead.
* Never add more type annotations than you need. Flow is really good at
inferring types from standard JS code; it will tell you
when you need to add explicit annotations.
* When a function like `getAllAddons` takes object arguments, call its
type object `GetAllAddonsParams`. Example:
````js
type GetAllAddonsParams = {|
categoryId: number,
|};
function getAllAddons({ categoryId }: GetAllAddonsParams = {}) {
...
}
````
* Use [Exact object types](https://flowtype.org/en/docs/types/objects/#toc-exact-object-types)
via the pipe syntax (`{| key: ... |}`) when possible. Sometimes the
spread operator triggers an error like
'Inexact type is incompatible with exact type' but that's a
[bug](https://github.com/facebook/flow/issues/2405).
You can use the `Exact<T>` workaround from
[`src/core/types/util`](https://github.com/mozilla/addons-frontend/blob/master/src/core/types/util.js)
if you have to. This is meant as a working replacement for
[$Exact<T>](https://flow.org/en/docs/types/utilities/#toc-exact).
* Try to avoid loose types like `Object` or `any` but feel free to use
them if you are spending too much time declaring types that depend on other
types that depend on other types, and so on.
* You can add a `$FLOW_FIXME` comment to skip a Flow check if you run
into a bug or if you hit something that's making you bang your head on
the keyboard. If it's something you think is unfixable then use
`$FLOW_IGNORE` instead. Please explain your rationale in the comment and link
to a GitHub issue if possible.
### Code coverage
The `npm run unittest` command generates a report of how well the unit tests

1
flow/flowStub.js.flow Normal file
Просмотреть файл

@ -0,0 +1 @@
// This file is just a stub that will suppress 'missing file' errors.

3
flow/libs/README.md Normal file
Просмотреть файл

@ -0,0 +1,3 @@
The files in this directory (when declared in `[libs]` of `.flowconfig`)
will declare global objects. These declarations should be a last resort.
Try to export/import types in the actual source code first.

6
flow/libs/dedent.js.flow Normal file
Просмотреть файл

@ -0,0 +1,6 @@
// TODO: this can go away after
// https://github.com/mozilla/addons-frontend/issues/2092
// I'm not sure exactly what this should be. I was using this issue as a guide.
// https://github.com/facebook/flow/issues/2616#issuecomment-289257544
declare function dedent(params: Array<*>): string;

1
flow/logs/README.md Normal file
Просмотреть файл

@ -0,0 +1 @@
Flow server logs are written to this directory.

Просмотреть файл

@ -14,6 +14,8 @@
"dev:amo:no-proxy": "better-npm-run dev:amo:no-proxy",
"dev:disco": "better-npm-run dev:disco",
"eslint": "eslint .",
"flow:check": "flow check",
"flow:dev": "chokidar .flowconfig flow/ src/ tests/ -i flow/logs/flow.log -c 'flow status' --initial",
"stylelint": "stylelint --syntax scss **/*.scss",
"lint": "npm run eslint && npm run stylelint",
"servertest": "bin/config-check.js && ADDONS_FRONTEND_BUILD_ALL=1 npm run build && better-npm-run servertest && better-npm-run servertest:amo && better-npm-run servertest:disco && better-npm-run servertest:admin",
@ -118,7 +120,7 @@
}
},
"test": {
"command": "npm run version-check && npm run unittest && npm run servertest && npm run eslint && npm run stylelint",
"command": "npm run version-check && npm run flow:check && npm run unittest && npm run servertest && npm run eslint && npm run stylelint",
"env": {
"NODE_PATH": "./:./src",
"NODE_ENV": "test"
@ -229,6 +231,7 @@
"babel-plugin-react-transform": "2.0.2",
"babel-plugin-transform-class-properties": "6.18.0",
"babel-plugin-transform-decorators-legacy": "1.3.4",
"babel-plugin-transform-flow-strip-types": "6.22.0",
"babel-plugin-transform-object-rest-spread": "6.20.2",
"babel-preset-es2015": "6.24.0",
"babel-preset-react": "6.16.0",
@ -238,6 +241,7 @@
"chai": "3.5.0",
"chalk": "1.1.3",
"cheerio": "0.22.0",
"chokidar-cli": "1.2.0",
"concurrently": "3.4.0",
"cookie": "0.3.1",
"css-loader": "0.28.0",
@ -249,6 +253,7 @@
"eslint-plugin-react": "6.10.3",
"fetch-mock": "5.9.4",
"file-loader": "0.10.1",
"flow-bin": "0.38.0",
"glob": "7.1.1",
"http-proxy": "1.16.2",
"json-loader": "0.5.4",

Просмотреть файл

@ -1,15 +1,32 @@
/* @flow */
import { SET_ADDON_REVIEWS, SET_REVIEW } from 'amo/constants';
import type { ApiReviewType } from 'amo/api';
export function denormalizeReview(review) {
export type UserReviewType = {|
addonId: number,
addonSlug: string,
body: string,
created: Date,
id: number,
isLatest: boolean,
rating: number,
title: string,
userId: number,
userName: string,
userUrl: string,
versionId: ?number,
|};
export function denormalizeReview(review: ApiReviewType): UserReviewType {
return {
addonId: review.addon.id,
addonSlug: review.addon.slug,
body: review.body,
created: review.created,
title: review.title,
id: review.id,
isLatest: review.is_latest,
rating: review.rating,
title: review.title,
userId: review.user.id,
userName: review.user.name,
userUrl: review.user.url,
@ -18,26 +35,43 @@ export function denormalizeReview(review) {
};
}
const setReviewAction = (review) => ({ type: SET_REVIEW, payload: review });
export type SetReviewAction = {|
type: string,
payload: UserReviewType,
|};
export const setReview = (review, reviewOverrides = {}) => {
export const setReview = (review: ApiReviewType): SetReviewAction => {
if (!review) {
throw new Error('review cannot be empty');
}
return setReviewAction({
...denormalizeReview(review),
...reviewOverrides,
});
return { type: SET_REVIEW, payload: denormalizeReview(review) };
};
export const setDenormalizedReview = (review) => {
export const setDenormalizedReview = (
review: UserReviewType
): SetReviewAction => {
if (!review) {
throw new Error('review cannot be empty');
}
return setReviewAction(review);
return { type: SET_REVIEW, payload: review };
};
export const setAddonReviews = ({ addonSlug, reviews }) => {
export type SetAddonReviewsAction = {|
type: string,
payload: {|
addonSlug: string,
reviews: Array<UserReviewType>,
|},
|};
type SetAddonReviewsParams = {|
addonSlug: string,
reviews: Array<ApiReviewType>,
|};
export const setAddonReviews = (
{ addonSlug, reviews }: SetAddonReviewsParams
): SetAddonReviewsAction => {
if (!addonSlug) {
throw new Error('addonSlug cannot be empty');
}

Просмотреть файл

@ -1,6 +1,43 @@
/* @flow */
import { callApi } from 'core/api';
import type { ApiStateType } from 'core/reducers/api';
import type { ErrorHandlerType } from 'core/errorHandler';
import log from 'core/logger';
export type ApiReviewType = {|
addon: {|
id: number,
slug: string,
|},
body: string,
created: Date,
id: number,
is_latest: boolean,
rating: number,
title: string,
user: {|
id: number,
name: string,
url: string,
|},
version: ?{|
id: number,
|},
|};
// TODO: make a separate function for posting/patching so that we
// can type check each one independently.
export type SubmitReviewParams = {|
addonId?: number,
apiState?: ApiStateType,
body?: string,
errorHandler?: ErrorHandlerType,
rating?: number,
reviewId?: number,
title?: string,
versionId?: number,
|};
/*
* POST/PATCH an add-on review using the API.
*/
@ -13,10 +50,16 @@ export function submitReview({
body,
reviewId,
...apiCallParams
}) {
}: SubmitReviewParams): Promise<ApiReviewType> {
return new Promise(
(resolve) => {
const data = { rating, version: versionId, body, title };
const review = {
addon: undefined,
rating,
version: versionId,
body,
title,
};
let method = 'POST';
let endpoint = 'reviews/review';
@ -24,17 +67,17 @@ export function submitReview({
endpoint = `${endpoint}/${reviewId}`;
method = 'PATCH';
// You cannot update the version of an existing review.
data.version = undefined;
review.version = undefined;
} else {
if (!addonId) {
throw new Error('addonId is required when posting a new review');
}
data.addon = addonId;
review.addon = addonId;
}
resolve(callApi({
endpoint,
body: data,
body: review,
method,
auth: true,
state: apiState,
@ -43,7 +86,14 @@ export function submitReview({
});
}
export function getReviews({ user, addon, ...params } = {}) {
type GetReviewsParams = {|
addon: number,
user: number,
|};
export function getReviews(
{ user, addon, ...params }: GetReviewsParams = {}
) {
return new Promise((resolve) => {
if (!user && !addon) {
throw new Error('Either user or addon must be specified');
@ -62,7 +112,14 @@ export function getReviews({ user, addon, ...params } = {}) {
});
}
export function getLatestUserReview({ user, addon } = {}) {
type GetLatestReviewParams = {|
addon: number,
user: number,
|};
export function getLatestUserReview(
{ user, addon }: GetLatestReviewParams = {}
) {
return new Promise((resolve) => {
if (!user || !addon) {
throw new Error('Both user and addon must be specified');

Просмотреть файл

@ -1,4 +1,7 @@
import React, { PropTypes } from 'react';
/* @flow */
/* global Node */
/* eslint-disable react/sort-comp, react/no-unused-prop-types */
import React from 'react';
import { connect } from 'react-redux';
import { compose } from 'redux';
@ -18,26 +21,43 @@ import {
import translate from 'core/i18n/translate';
import log from 'core/logger';
import DefaultRating from 'ui/components/Rating';
import type { ErrorHandlerType } from 'core/errorHandler';
import type { UserReviewType } from 'amo/actions/reviews';
import type { SubmitReviewParams } from 'amo/api';
import type { UrlFormatParams } from 'core/api';
import type { ApiStateType } from 'core/reducers/api';
import type { DispatchFunc } from 'core/types/redux';
import type { AddonType, AddonVersionType } from 'core/types/addons';
import './styles.scss';
type LoadSavedReviewFunc = ({|
userId: number,
addonId: number,
|}) => Promise<any>;
type SubmitReviewFunc = (SubmitReviewParams) => Promise<void>;
type RatingManagerProps = {|
AddonReview: typeof DefaultAddonReview,
AuthenticateButton: typeof DefaultAuthenticateButton,
Rating: typeof DefaultRating,
addon: AddonType,
apiState: ApiStateType,
errorHandler: ErrorHandlerType,
i18n: Object,
loadSavedReview: LoadSavedReviewFunc,
location: UrlFormatParams,
submitReview: SubmitReviewFunc,
userId: number,
userReview: UserReviewType,
version: AddonVersionType,
|};
export class RatingManagerBase extends React.Component {
static propTypes = {
AddonReview: PropTypes.node,
AuthenticateButton: PropTypes.node,
addon: PropTypes.object.isRequired,
errorHandler: PropTypes.func.isRequired,
apiState: PropTypes.object,
i18n: PropTypes.object.isRequired,
loadSavedReview: PropTypes.func.isRequired,
location: PropTypes.object.isRequired,
Rating: PropTypes.node,
submitReview: PropTypes.func.isRequired,
userId: PropTypes.number,
userReview: PropTypes.object,
version: PropTypes.object.isRequired,
}
props: RatingManagerProps;
ratingLegend: Node;
state: {| showTextEntry: boolean |};
static defaultProps = {
AddonReview: DefaultAddonReview,
@ -45,7 +65,7 @@ export class RatingManagerBase extends React.Component {
Rating: DefaultRating,
}
constructor(props) {
constructor(props: RatingManagerProps) {
super(props);
const { loadSavedReview, userId, addon } = props;
this.state = { showTextEntry: false };
@ -55,16 +75,16 @@ export class RatingManagerBase extends React.Component {
}
}
onSelectRating = (rating) => {
const { userId, userReview, version } = this.props;
onSelectRating = (rating: number) => {
const { userReview, version } = this.props;
const params = {
errorHandler: this.props.errorHandler,
rating,
apiState: this.props.apiState,
addonId: this.props.addon.id,
reviewId: undefined,
versionId: version.id,
userId,
};
if (userReview) {
@ -89,7 +109,12 @@ export class RatingManagerBase extends React.Component {
}
getLogInPrompt(
{ addonType }, { validAddonTypes = defaultValidAddonTypes } = {}
{ addonType }: {| addonType: string |},
{
validAddonTypes = defaultValidAddonTypes,
}: {|
validAddonTypes: typeof defaultValidAddonTypes,
|} = {}
) {
const { i18n } = this.props;
switch (addonType) {
@ -165,7 +190,10 @@ export class RatingManagerBase extends React.Component {
}
}
export const mapStateToProps = (state, ownProps) => {
// TODO: when all state types are exported, define `state`.
export const mapStateToProps = (
state: Object, ownProps: RatingManagerProps
) => {
const userId = state.auth && state.auth.userId;
let userReview;
@ -193,7 +221,14 @@ export const mapStateToProps = (state, ownProps) => {
};
};
export const mapDispatchToProps = (dispatch) => ({
type DispatchMappedProps = {|
loadSavedReview: LoadSavedReviewFunc,
submitReview: SubmitReviewFunc,
|}
export const mapDispatchToProps = (
dispatch: DispatchFunc
): DispatchMappedProps => ({
loadSavedReview({ userId, addonId }) {
return getLatestUserReview({ user: userId, addon: addonId })

Просмотреть файл

@ -1,3 +1,4 @@
/* @flow */
import {
ENTITIES_LOADED,
LOG_OUT_USER,
@ -8,7 +9,12 @@ import {
SET_USER_AGENT,
} from 'core/constants';
export function setAuthToken(token) {
export type SetAuthTokenAction = {|
payload: {| token: string |},
type: string,
|};
export function setAuthToken(token: string): SetAuthTokenAction {
if (!token) {
throw new Error('token cannot be falsey');
}
@ -18,11 +24,20 @@ export function setAuthToken(token) {
};
}
export function logOutUser() {
export type LogOutUserAction = {|
type: string,
|};
export function logOutUser(): LogOutUserAction {
return { type: LOG_OUT_USER };
}
export function setClientApp(clientApp) {
export type SetClientAppAction = {|
payload: {| clientApp: string |},
type: string,
|};
export function setClientApp(clientApp: string): SetClientAppAction {
if (!clientApp) {
throw new Error('clientApp cannot be falsey');
}
@ -32,28 +47,48 @@ export function setClientApp(clientApp) {
};
}
export function setLang(lang) {
export type SetLangAction = {|
payload: {| lang: string |},
type: string,
|};
export function setLang(lang: string): SetLangAction {
return {
type: SET_LANG,
payload: { lang },
};
}
export function setUserAgent(userAgent) {
export type SetUserAgentAction = {|
payload: {| userAgent: string |},
type: string,
|};
export function setUserAgent(userAgent: string): SetUserAgentAction {
return {
type: SET_USER_AGENT,
payload: { userAgent },
};
}
export function loadEntities(entities) {
export type LoadEntitiesAction = {|
payload: {| entities: Array<Object> |},
type: string,
|};
export function loadEntities(entities: Array<Object>): LoadEntitiesAction {
return {
type: ENTITIES_LOADED,
payload: { entities },
};
}
export function setCurrentUser(username) {
export type SetCurrentUserAction = {|
payload: {| username: string |},
type: string,
|};
export function setCurrentUser(username: string): SetCurrentUserAction {
return {
type: SET_CURRENT_USER,
payload: { username },

Просмотреть файл

@ -1,3 +1,4 @@
/* @flow */
/* global fetch */
import url from 'url';
@ -8,9 +9,12 @@ import { schema as normalizrSchema, normalize } from 'normalizr';
import { oneLine } from 'common-tags';
import config from 'config';
import { initialApiState } from 'core/reducers/api';
import { ADDON_TYPE_THEME } from 'core/constants';
import log from 'core/logger';
import { convertFiltersToQueryParams } from 'core/searchUtils';
import type { ErrorHandlerType } from 'core/errorHandler';
import type { ApiStateType } from 'core/reducers/api';
const API_BASE = `${config.get('apiHost')}${config.get('apiPath')}`;
@ -20,7 +24,7 @@ export const addon = new Entity('addons', {}, { idAttribute: 'slug' });
export const category = new Entity('categories', {}, { idAttribute: 'slug' });
export const user = new Entity('users', {}, { idAttribute: 'username' });
export function makeQueryString(query) {
export function makeQueryString(query: { [key: string]: * }) {
const resolvedQuery = { ...query };
Object.keys(resolvedQuery).forEach((key) => {
const value = resolvedQuery[key];
@ -33,7 +37,15 @@ export function makeQueryString(query) {
return url.format({ query: resolvedQuery });
}
export function createApiError({ apiURL, response, jsonResponse }) {
type CreateApiErrorParams = {|
apiURL?: string,
response: { status: number },
jsonResponse?: Object,
|};
export function createApiError(
{ apiURL, response, jsonResponse }: CreateApiErrorParams
) {
let urlId = '[unknown URL]';
if (apiURL) {
// Strip the host since we already know that.
@ -42,6 +54,7 @@ export function createApiError({ apiURL, response, jsonResponse }) {
urlId = urlId.split('?')[0];
}
const apiError = new Error(`Error calling: ${urlId}`);
// $FLOW_FIXME: turn Error into a custom ApiError class.
apiError.response = {
apiURL,
status: response.status,
@ -50,10 +63,29 @@ export function createApiError({ apiURL, response, jsonResponse }) {
return apiError;
}
type CallApiParams = {|
auth?: boolean,
body?: Object,
credentials?: boolean,
endpoint: string,
errorHandler?: ErrorHandlerType,
method?: 'GET' | 'POST' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'PUT' | 'PATCH',
params?: Object,
schema?: Object,
state?: ApiStateType,
|};
export function callApi({
endpoint, schema, params = {}, auth = false, state = {}, method = 'get',
body, credentials, errorHandler,
}) {
endpoint,
schema,
params = {},
auth = false,
state = initialApiState,
method = 'GET',
body,
credentials,
errorHandler,
}: CallApiParams): Promise<any> {
if (errorHandler) {
errorHandler.clear();
}
@ -63,6 +95,8 @@ export function callApi({
// Always make sure the method is upper case so that the browser won't
// complain about CORS problems.
method: method.toUpperCase(),
credentials: undefined,
body: undefined,
};
if (credentials) {
options.credentials = 'include';
@ -79,6 +113,7 @@ export function callApi({
// Workaround for https://github.com/bitinn/node-fetch/issues/245
const apiURL = utf8.encode(`${API_BASE}/${endpoint}/${queryString}`);
// $FLOW_FIXME: once everything uses Flow we won't have to use toUpperCase
return fetch(apiURL, options)
.then((response) => {
const contentType = response.headers.get('Content-Type').toLowerCase();
@ -124,7 +159,16 @@ export function callApi({
.then((response) => (schema ? normalize(response, schema) : response));
}
export function search({ api, page, auth = false, filters = {} }) {
type SearchParams = {|
api: ApiStateType,
auth: boolean,
filters: Object,
page: number,
|};
export function search(
{ api, page, auth = false, filters = {} }: SearchParams
) {
const _filters = { ...filters };
if (!_filters.clientApp && api.clientApp) {
log.debug(
@ -158,7 +202,12 @@ export function search({ api, page, auth = false, filters = {} }) {
});
}
export function fetchAddon({ api, slug }) {
type FetchAddonParams = {|
api: ApiStateType,
slug: string,
|};
export function fetchAddon({ api, slug }: FetchAddonParams) {
return callApi({
endpoint: `addons/addon/${slug}`,
schema: addon,
@ -167,15 +216,21 @@ export function fetchAddon({ api, slug }) {
});
}
export function login({ api, code, state }) {
const params = {};
type LoginParams = {|
api: ApiStateType,
code: string,
state: string,
|};
export function login({ api, code, state }: LoginParams) {
const params = { config: undefined };
const configName = config.get('fxaConfig');
if (configName) {
params.config = configName;
}
return callApi({
endpoint: 'accounts/login',
method: 'post',
method: 'POST',
body: { code, state },
params,
state: api,
@ -183,9 +238,27 @@ export function login({ api, code, state }) {
});
}
export function startLoginUrl({ location }) {
// These are all possible parameters to url.format()
export type UrlFormatParams = {|
+auth?: string;
+hash?: string;
+host?: string;
+hostname?: string;
+href?: string;
+pathname?: string;
+port?: string | number;
+protocol?: string;
+query?: Object;
+search?: string;
+slashes?: boolean;
|};
export function startLoginUrl({ location }: { location: UrlFormatParams }) {
const configName = config.get('fxaConfig');
const params = { to: url.format({ ...location }) };
const params = {
config: undefined,
to: url.format({ ...location }),
};
if (configName) {
params.config = configName;
}
@ -193,7 +266,7 @@ export function startLoginUrl({ location }) {
return `${API_BASE}/accounts/login/start/${query}`;
}
export function fetchProfile({ api }) {
export function fetchProfile({ api }: {| api: ApiStateType |}) {
return callApi({
endpoint: 'accounts/profile',
schema: user,
@ -202,7 +275,13 @@ export function fetchProfile({ api }) {
});
}
export function featured({ api, filters, page }) {
type FeaturedParams = {|
api: ApiStateType,
filters: Object,
page: number,
|};
export function featured({ api, filters, page }: FeaturedParams) {
return callApi({
endpoint: 'addons/featured',
params: {
@ -215,7 +294,7 @@ export function featured({ api, filters, page }) {
});
}
export function categories({ api }) {
export function categories({ api }: {| api: ApiStateType |}) {
return callApi({
endpoint: 'addons/categories',
schema: { results: [category] },
@ -223,12 +302,12 @@ export function categories({ api }) {
});
}
export function logOutFromServer({ api }) {
export function logOutFromServer({ api }: {| api: ApiStateType |}) {
return callApi({
auth: true,
credentials: true,
endpoint: 'accounts/session',
method: 'delete',
method: 'DELETE',
state: api,
});
}

Просмотреть файл

@ -1,5 +1,7 @@
/* global window */
import React, { PropTypes } from 'react';
/* @flow */
/* global Event, window */
/* eslint-disable react/sort-comp */
import React from 'react';
import { connect } from 'react-redux';
import { compose } from 'redux';
@ -8,27 +10,37 @@ import { logOutFromServer, startLoginUrl } from 'core/api';
import translate from 'core/i18n/translate';
import Button from 'ui/components/Button';
import Icon from 'ui/components/Icon';
import type { UrlFormatParams } from 'core/api';
import type { ApiStateType } from 'core/reducers/api';
import type { DispatchFunc } from 'core/types/redux';
type HandleLogInFunc = (
location: UrlFormatParams, options?: {| _window: typeof window |}
) => void;
type HandleLogOutFunc = ({| api: ApiStateType |}) => Promise<void>;
type AuthenticateButtonProps = {|
api: ApiStateType,
className?: string,
handleLogIn: HandleLogInFunc,
handleLogOut: HandleLogOutFunc,
i18n: Object,
isAuthenticated: boolean,
location: UrlFormatParams,
logInText?: string,
logOutText?: string,
noIcon: boolean,
|};
export class AuthenticateButtonBase extends React.Component {
static propTypes = {
api: PropTypes.object.isRequired,
className: PropTypes.string,
handleLogIn: PropTypes.func.isRequired,
handleLogOut: PropTypes.func.isRequired,
i18n: PropTypes.object.isRequired,
isAuthenticated: PropTypes.bool.isRequired,
location: PropTypes.object.isRequired,
logInText: PropTypes.string,
logOutText: PropTypes.string,
noIcon: PropTypes.boolean,
}
props: AuthenticateButtonProps;
static defaultProps = {
noIcon: false,
}
onClick = (event) => {
onClick = (event: Event) => {
event.preventDefault();
event.stopPropagation();
const {
@ -57,7 +69,15 @@ export class AuthenticateButtonBase extends React.Component {
}
}
export const mapStateToProps = (state) => ({
type StateMappedProps = {|
api: ApiStateType,
isAuthenticated: boolean,
handleLogIn: HandleLogInFunc,
|};
export const mapStateToProps = (
state: {| api: ApiStateType |}
): StateMappedProps => ({
api: state.api,
isAuthenticated: !!state.api.token,
handleLogIn(location, { _window = window } = {}) {
@ -66,7 +86,13 @@ export const mapStateToProps = (state) => ({
},
});
export const mapDispatchToProps = (dispatch) => ({
type DispatchMappedProps = {|
handleLogOut: HandleLogOutFunc,
|};
export const mapDispatchToProps = (
dispatch: DispatchFunc
): DispatchMappedProps => ({
handleLogOut({ api }) {
return logOutFromServer({ api })
.then(() => dispatch(logOutUser()));

Просмотреть файл

@ -45,6 +45,8 @@ export class ErrorHandler {
}
}
export type ErrorHandlerType = typeof ErrorHandler;
/*
* This is a decorator that gives a component the ability to handle errors.
*

Просмотреть файл

@ -1,3 +1,4 @@
/* @flow */
import config from 'config';
import Jed from 'jed';
import moment from 'moment';
@ -13,7 +14,7 @@ const supportedLangs = langs.concat(Object.keys(langMap));
const rtlLangs = config.get('rtlLangs');
export function localeToLang(locale, log_ = log) {
export function localeToLang(locale?: any, log_?: typeof log = log) {
let lang;
if (locale && locale.split) {
const parts = locale.split('_');
@ -34,7 +35,7 @@ export function localeToLang(locale, log_ = log) {
return lang;
}
export function langToLocale(language, log_ = log) {
export function langToLocale(language?: any, log_?: typeof log = log) {
let locale;
if (language && language.split) {
const parts = language.split('-');
@ -55,23 +56,36 @@ export function langToLocale(language, log_ = log) {
return locale;
}
export function normalizeLang(lang) {
export function normalizeLang(lang?: string) {
return localeToLang(langToLocale(lang));
}
export function normalizeLocale(locale) {
export function normalizeLocale(locale: string) {
return langToLocale(localeToLang(locale));
}
export function isSupportedLang(lang, { _supportedLangs = supportedLangs } = {}) {
type IsSupportedLangOptions = {|
_supportedLangs: typeof supportedLangs,
|};
export function isSupportedLang(
lang?: string,
{ _supportedLangs = supportedLangs }: IsSupportedLangOptions = {}
) {
return _supportedLangs.includes(lang);
}
export function isValidLang(lang, { _langs = langs } = {}) {
type IsValidLangOptions = {|
_langs: typeof langs,
|};
export function isValidLang(
lang?: string, { _langs = langs }: IsValidLangOptions = {}
) {
return _langs.includes(lang);
}
export function sanitizeLanguage(langOrLocale) {
export function sanitizeLanguage(langOrLocale?: string) {
let language = normalizeLang(langOrLocale);
// Only look in the un-mapped lang list.
if (!isValidLang(language)) {
@ -81,12 +95,12 @@ export function sanitizeLanguage(langOrLocale) {
return language;
}
export function isRtlLang(lang) {
export function isRtlLang(lang: string) {
const language = sanitizeLanguage(lang);
return rtlLangs.includes(language);
}
export function getDirection(lang) {
export function getDirection(lang: string) {
return isRtlLang(lang) ? 'rtl' : 'ltr';
}
@ -105,7 +119,7 @@ function qualityCmp(a, b) {
* sorted array of objects. Example object:
* { lang: 'pl', quality: 0.7 }
*/
export function parseAcceptLanguage(header) {
export function parseAcceptLanguage(header: string) {
// pl,fr-FR;q=0.3,en-US;q=0.1
if (!header || !header.split) {
return [];
@ -129,6 +143,10 @@ export function parseAcceptLanguage(header) {
return langList;
}
type GetLangFromHeaderOptions = {|
_supportedLangs?: Object,
|};
/*
* Given an accept-language header and a list of currently
@ -137,7 +155,9 @@ export function parseAcceptLanguage(header) {
* Note: this doesn't map languages e.g. pt -> pt-PT. Use sanitizeLanguage for that.
*
*/
export function getLangFromHeader(acceptLanguage, { _supportedLangs } = {}) {
export function getLangFromHeader(
acceptLanguage: string, { _supportedLangs }: GetLangFromHeaderOptions = {}
) {
let userLang;
if (acceptLanguage) {
const langList = parseAcceptLanguage(acceptLanguage);
@ -156,13 +176,18 @@ export function getLangFromHeader(acceptLanguage, { _supportedLangs } = {}) {
return normalizeLang(userLang);
}
type GetLanguageParams = {|
lang: string,
acceptLanguage: string,
|};
/*
* Check validity of language:
* - If invalid, fall-back to accept-language.
* - Return object with lang and isLangFromHeader hint.
*
*/
export function getLanguage({ lang, acceptLanguage } = {}) {
export function getLanguage({ lang, acceptLanguage }: GetLanguageParams = {}) {
let userLang = lang;
let isLangFromHeader = false;
// If we don't have a supported userLang yet try accept-language.
@ -177,7 +202,7 @@ export function getLanguage({ lang, acceptLanguage } = {}) {
}
// moment uses locales like "en-gb" whereas we use "en_GB".
export function makeMomentLocale(locale) {
export function makeMomentLocale(locale: string) {
return locale.replace('_', '-').toLowerCase();
}
@ -191,12 +216,32 @@ function oneLineTranslationString(translationKey) {
return translationKey;
}
type I18nConfig = {|
// The following keys configure Jed.
// See http://messageformat.github.io/Jed/
domain: string,
locale_data: {
[domain: string]: {
'': { // an empty string configures the domain.
domain: string,
lang: string,
plural_forms: string,
},
[message: string]: Array<string>,
},
},
// This is our custom configuration for moment.
_momentDefineLocale?: Function,
|};
// Create an i18n object with a translated moment object available we can
// use for translated dates across the app.
export function makeI18n(i18nData, lang, _Jed = Jed) {
export function makeI18n(i18nData: I18nConfig, lang: string, _Jed: Jed = Jed) {
const i18n = new _Jed(i18nData);
i18n.lang = lang;
// TODO: move all of this to an I18n class that extends Jed so that we
// can type-check all the components that rely on the i18n object.
i18n.formatNumber = (number) => number.toLocaleString(lang);
// This adds the correct moment locale for the active locale so we can get

Просмотреть файл

@ -1,3 +1,4 @@
/* @flow */
import UAParser from 'ua-parser-js';
import {
@ -7,8 +8,44 @@ import {
SET_CLIENT_APP,
SET_USER_AGENT,
} from 'core/constants';
import type {
SetAuthTokenAction,
LogOutUserAction,
SetClientAppAction,
SetLangAction,
SetUserAgentAction,
} from 'core/actions/index';
import type { Exact } from 'core/types/util';
export default function api(state = {}, action) {
type UserAgentInfoType = {|
browser: string,
os: string,
|};
export type ApiStateType = {
clientApp: ?string,
lang: ?string,
token: ?string,
userAgent: ?string,
userAgentInfo: ?UserAgentInfoType,
};
export const initialApiState = {
clientApp: null,
lang: null,
token: null,
userAgent: null,
userAgentInfo: null,
};
export default function api(
state: Exact<ApiStateType> = initialApiState,
action: SetAuthTokenAction
& SetLangAction
& SetClientAppAction
& SetUserAgentAction
& LogOutUserAction
): Exact<ApiStateType> {
switch (action.type) {
case SET_AUTH_TOKEN:
return { ...state, token: action.payload.token };
@ -27,11 +64,7 @@ export default function api(state = {}, action) {
};
}
case LOG_OUT_USER:
{
const newState = { ...state };
delete newState.token;
return newState;
}
return { ...state, token: null };
default:
return state;
}

68
src/core/types/addons.js Normal file
Просмотреть файл

@ -0,0 +1,68 @@
/* @flow */
export type AddonVersionType = {|
channel: string,
edit_url: string,
files: Array<Object>,
id: number,
// The `text` property is omitted from addon.current_version.license.
license: { name: string, url: string },
reviewed: Date,
version: string,
|};
export type AddonAuthorType = {|
name: string,
url: string,
|};
export type AddonType = {|
authors: Array<AddonAuthorType>,
average_daily_users: number,
categories: Object,
compatibility: Object,
current_version: AddonVersionType,
default_locale: string,
description: string,
edit_url: string,
guid: string,
has_eula: boolean,
has_privacy_policy: boolean,
homepage: string,
icon_url: string,
id: number,
is_disabled: boolean,
is_experimental: boolean,
is_source_public: boolean,
last_updated: Date,
latest_unlisted_version: ?AddonVersionType,
name: string,
previews: Array<Object>,
public_stats: boolean,
ratings: {|
average: number,
count: number,
|},
review_url: string,
slug: string,
status:
| 'beta'
| 'lite'
| 'public'
| 'deleted'
| 'pending'
| 'disabled'
| 'rejected'
| 'nominated'
| 'incomplete'
| 'unreviewed'
| 'lite-nominated'
| 'review-pending',
summary: string,
support_email: string,
support_url: string,
tags: Array<string>,
theme_data: Object,
type: string,
url: string,
weekly_downloads: number,
|};

7
src/core/types/redux.js Normal file
Просмотреть файл

@ -0,0 +1,7 @@
/* @flow */
// This defines some Redux interfaces that we want to depend on.
// It may be possible to use an official library for this.
// See: https://github.com/reactjs/react-redux/pull/389
// and: https://github.com/reactjs/redux/pull/1887/files#diff-46d86d39c8da613247f843ee8ca43ebc
export type DispatchFunc = (action: Object) => void;

30
src/core/types/util.js Normal file
Просмотреть файл

@ -0,0 +1,30 @@
/* @flow */
/* global $Shape */
// TODO: This can go away when https://github.com/facebook/flow/issues/2405
// is fixed.
//
// Exact is a fixed version of the $Exact object type, which you typically
// see used as pipes within an object type:
// https://flowtype.org/en/docs/types/objects/#toc-exact-object-types
// It is available as a generic utility called $Exact<Object> too:
// https://flow.org/en/docs/types/utilities/#toc-exact
//
// At the time of this writing, you may need to use this workaround if you
// want to use the spread operator to merge objects together. See:
// https://github.com/facebook/flow/issues/2405#issuecomment-274073091
//
// Usage:
//
// Let's say you have a loosely defined parameter type for a function like this:
//
// type GetAllAddonsParams = {
// categoryId: number,
// };
//
// You can make sure this function accepts *only* these parameters like this:
//
// function getAllAddons({ categoryId }: Exact<GetAllAddonsParams> = {}) {
// ...
// }
export type Exact<T> = T & $Shape<T>;

Просмотреть файл

@ -21,6 +21,7 @@ import translate from 'core/i18n/translate';
import { loadEntities } from 'core/actions';
import * as coreApi from 'core/api';
import { denormalizeAddon } from 'core/reducers/addons';
import { initialApiState } from 'core/reducers/api';
import I18nProvider from 'core/i18n/Provider';
import Rating from 'ui/components/Rating';
import { fakeAddon, fakeReview } from 'tests/client/amo/helpers';
@ -254,7 +255,7 @@ describe('amo/components/AddonReviewList', () => {
mockCoreApi
.expects('fetchAddon')
.once()
.withArgs({ slug: addonSlug, api: {} })
.withArgs({ slug: addonSlug, api: { ...initialApiState } })
.returns(Promise.resolve(createFetchAddonResult(fakeAddon)));
mockAmoApi

Просмотреть файл

@ -14,6 +14,7 @@ import {
ADDON_TYPE_THEME,
} from 'core/constants';
import I18nProvider from 'core/i18n/Provider';
import { initialApiState } from 'core/reducers/api';
import * as amoApi from 'amo/api';
import createStore from 'amo/store';
import { setReview } from 'amo/actions/reviews';
@ -80,7 +81,6 @@ describe('RatingManager', () => {
apiState: { ...signedInApiState, token: 'new-token' },
version: { id: 321 },
addon: { ...fakeAddon, id: 12345, slug: 'some-slug' },
userId: 92345,
});
return root.onSelectRating(5)
.then(() => {
@ -91,7 +91,6 @@ describe('RatingManager', () => {
assert.equal(call.apiState.token, 'new-token');
assert.equal(call.addonId, 12345);
assert.equal(call.errorHandler, errorHandler);
assert.equal(call.userId, 92345);
assert.strictEqual(call.reviewId, undefined);
});
});
@ -374,7 +373,7 @@ describe('RatingManager', () => {
});
it('sets an empty apiState when not signed in', () => {
assert.deepEqual(getMappedProps().apiState, {});
assert.deepEqual(getMappedProps().apiState, { ...initialApiState });
});
it('sets an empty userId when not signed in', () => {
@ -395,7 +394,7 @@ describe('RatingManager', () => {
it('sets a user review to the latest matching one in state', () => {
signIn({ userId: fakeReview.user.id });
const action = setReview(fakeReview, { isLatest: true });
const action = setReview({ ...fakeReview, is_latest: true });
store.dispatch(action);
const dispatchedReview = action.payload;
@ -411,9 +410,13 @@ describe('RatingManager', () => {
signIn({ userId: userIdOne });
// Save a review for user two.
store.dispatch(setReview(fakeReview, {
isLatest: true,
userId: userIdTwo,
store.dispatch(setReview({
...fakeReview,
is_latest: true,
user: {
...fakeReview.user,
id: userIdTwo,
},
rating: savedRating,
}));
@ -435,18 +438,18 @@ describe('RatingManager', () => {
signIn({ userId: fakeReview.user.id });
function createReview(overrides) {
const action = setReview(fakeReview, overrides);
const action = setReview({ ...fakeReview, ...overrides });
store.dispatch(action);
return action.payload;
}
createReview({
id: 1,
isLatest: false,
is_latest: false,
});
const latestReview = createReview({
id: 2,
isLatest: true,
is_latest: true,
});
assert.deepEqual(getMappedProps().userReview, latestReview);

Просмотреть файл

@ -3,13 +3,37 @@ import reviews, { initialState } from 'amo/reducers/reviews';
import { fakeAddon, fakeReview } from 'tests/client/amo/helpers';
describe('amo.reducers.reviews', () => {
function setFakeReview({
userId = fakeReview.user.id,
addonId = fakeReview.addon.id,
versionId = fakeReview.version.id,
...overrides } = {}
) {
return setReview({
...fakeReview,
user: {
...fakeReview.user,
id: userId,
},
addon: {
...fakeReview.addon,
id: addonId,
},
version: {
...fakeReview.version,
id: versionId,
},
...overrides,
});
}
it('defaults to an empty object', () => {
assert.deepEqual(reviews(undefined, { type: 'SOME_OTHER_ACTION' }),
initialState);
});
it('stores a user review', () => {
const action = setReview(fakeReview);
const action = setFakeReview();
const state = reviews(undefined, action);
const storedReview =
state[fakeReview.user.id][fakeReview.addon.id][fakeReview.id];
@ -32,21 +56,21 @@ describe('amo.reducers.reviews', () => {
it('preserves existing user rating data', () => {
let state;
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 1,
userId: 1,
addonId: 1,
rating: 1,
}));
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 2,
userId: 1,
addonId: 2,
rating: 5,
}));
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 3,
userId: 2,
addonId: 2,
@ -64,17 +88,17 @@ describe('amo.reducers.reviews', () => {
const userId = fakeReview.user.id;
const addonId = fakeReview.addon.id;
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 1,
versionId: 1,
}));
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 2,
versionId: 2,
}));
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 3,
versionId: 3,
}));
@ -87,7 +111,7 @@ describe('amo.reducers.reviews', () => {
it('preserves unrelated state', () => {
let state = { ...initialState, somethingUnrelated: 'erp' };
state = reviews(state, setReview(fakeReview));
state = reviews(state, setFakeReview());
assert.equal(state.somethingUnrelated, 'erp');
});
@ -96,19 +120,19 @@ describe('amo.reducers.reviews', () => {
const userId = fakeReview.user.id;
let state;
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 1,
isLatest: true,
is_latest: true,
}));
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 2,
isLatest: true,
is_latest: true,
}));
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 3,
isLatest: true,
is_latest: true,
}));
// Make sure only the newest submitted one is the latest:
@ -122,14 +146,14 @@ describe('amo.reducers.reviews', () => {
const userId = fakeReview.user.id;
let state;
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 1,
isLatest: true,
is_latest: true,
}));
state = reviews(state, setReview(fakeReview, {
state = reviews(state, setFakeReview({
id: 2,
isLatest: false,
is_latest: false,
}));
assert.equal(state[userId][addonId][1].isLatest, true);

Просмотреть файл

@ -18,10 +18,11 @@ describe('amo.api', () => {
// These are all the default values for fields that can be posted to the
// endpoint.
const defaultParams = {
rating: undefined,
version: undefined,
addon: undefined,
body: undefined,
rating: undefined,
title: undefined,
version: undefined,
};
const baseParams = {
apiState: { ...signedInApiState, token: 'new-token' },

Просмотреть файл

@ -5,6 +5,7 @@ import createStore from 'amo/store';
import * as featuredActions from 'amo/actions/featured';
import * as landingActions from 'amo/actions/landing';
import * as api from 'core/api';
import { initialApiState } from 'core/reducers/api';
import {
ADDON_TYPE_EXTENSION,
ADDON_TYPE_THEME,
@ -39,7 +40,9 @@ describe('amo/utils', () => {
mockApi
.expects('featured')
.once()
.withArgs({ api: {}, filters: { addonType, page_size: 25 } })
.withArgs({
api: { ...initialApiState }, filters: { addonType, page_size: 25 },
})
.returns(Promise.resolve({ entities, result }));
return loadFeaturedAddons({ store, params: ownProps.params })
@ -61,13 +64,15 @@ describe('amo/utils', () => {
mockApi
.expects('featured')
.once()
.withArgs({ api: {}, filters: { addonType, page_size: 4 } })
.withArgs({
api: { ...initialApiState }, filters: { addonType, page_size: 4 },
})
.returns(Promise.resolve({ entities, result }));
mockApi
.expects('search')
.once()
.withArgs({
api: {},
api: { ...initialApiState },
filters: { addonType, page_size: 4, sort: SEARCH_SORT_TOP_RATED },
page: 1,
})
@ -76,7 +81,7 @@ describe('amo/utils', () => {
.expects('search')
.once()
.withArgs({
api: {},
api: { ...initialApiState },
filters: { addonType, page_size: 4, sort: SEARCH_SORT_POPULAR },
page: 1,
})

Просмотреть файл

@ -1,4 +1,6 @@
/* global Response, window */
import querystring from 'querystring';
import config from 'config';
import utf8 from 'utf8';
@ -51,7 +53,7 @@ describe('api', () => {
it('transforms method to upper case', () => {
mockWindow.expects('fetch')
.withArgs(`${apiHost}/api/v3/resource/`, {
method: 'GET', headers: {},
body: undefined, credentials: undefined, method: 'GET', headers: {},
})
.once()
.returns(createApiResponse());
@ -63,7 +65,7 @@ describe('api', () => {
const endpoint = 'diccionario-español-venezuela';
mockWindow.expects('fetch')
.withArgs(utf8.encode(`${apiHost}/api/v3/${endpoint}/`), {
method: 'GET', headers: {},
body: undefined, credentials: undefined, method: 'GET', headers: {},
})
.once()
.returns(createApiResponse());
@ -156,7 +158,7 @@ describe('api', () => {
mockWindow.expects('fetch')
.withArgs(`${apiHost}/api/v3/resource/`, {
method: 'GET', headers: {},
body: undefined, credentials: undefined, method: 'GET', headers: {},
})
.once()
.returns(response);
@ -395,9 +397,12 @@ describe('api', () => {
it('sets the lang and slug', () => {
mockWindow.expects('fetch')
.withArgs(
`${apiHost}/api/v3/addons/addon/foo/?lang=en-US`,
{ headers: {}, method: 'GET' })
.withArgs(`${apiHost}/api/v3/addons/addon/foo/?lang=en-US`, {
body: undefined,
credentials: undefined,
headers: {},
method: 'GET',
})
.once()
.returns(mockResponse());
return api.fetchAddon({ api: { lang: 'en-US' }, slug: 'foo' })
@ -417,9 +422,12 @@ describe('api', () => {
it('fails when the add-on is not found', () => {
mockWindow
.expects('fetch')
.withArgs(
`${apiHost}/api/v3/addons/addon/foo/?lang=en-US`,
{ headers: {}, method: 'GET' })
.withArgs(`${apiHost}/api/v3/addons/addon/foo/?lang=en-US`, {
body: undefined,
credentials: undefined,
headers: {},
method: 'GET',
})
.once()
.returns(mockResponse({ ok: false }));
return api.fetchAddon({ api: { lang: 'en-US' }, slug: 'foo' })
@ -434,9 +442,12 @@ describe('api', () => {
const token = userAuthToken();
mockWindow
.expects('fetch')
.withArgs(
`${apiHost}/api/v3/addons/addon/bar/?lang=en-US`,
{ headers: { authorization: `Bearer ${token}` }, method: 'GET' })
.withArgs(`${apiHost}/api/v3/addons/addon/bar/?lang=en-US`, {
body: undefined,
credentials: undefined,
headers: { authorization: `Bearer ${token}` },
method: 'GET',
})
.once()
.returns(mockResponse());
return api.fetchAddon({ api: { lang: 'en-US', token }, slug: 'bar' })
@ -522,6 +533,8 @@ describe('api', () => {
mockWindow
.expects('fetch')
.withArgs(`${apiHost}/api/v3/accounts/profile/?lang=en-US`, {
body: undefined,
credentials: undefined,
headers: { authorization: `Bearer ${token}` },
method: 'GET',
})
@ -539,19 +552,19 @@ describe('api', () => {
});
describe('startLoginUrl', () => {
const getStartLoginQs = (location) =>
querystring.parse(api.startLoginUrl({ location }).split('?')[1]);
it('includes the next path', () => {
const location = { pathname: '/foo', query: { bar: 'BAR' } };
assert.equal(
api.startLoginUrl({ location }),
`${apiHost}/api/v3/accounts/login/start/?to=%2Ffoo%3Fbar%3DBAR`);
assert.deepEqual(getStartLoginQs(location), { to: '/foo?bar=BAR' });
});
it('includes the next path the config if set', () => {
sinon.stub(config, 'get').withArgs('fxaConfig').returns('my-config');
const location = { pathname: '/foo' };
assert.equal(
api.startLoginUrl({ location }),
`${apiHost}/api/v3/accounts/login/start/?to=%2Ffoo&config=my-config`);
assert.deepEqual(
getStartLoginQs(location), { to: '/foo', config: 'my-config' });
});
});
@ -587,6 +600,7 @@ describe('api', () => {
const mockResponse = createApiResponse({ jsonData: { ok: true } });
mockWindow.expects('fetch')
.withArgs(`${apiHost}/api/v3/accounts/session/?lang=en-US`, {
body: undefined,
credentials: 'include',
headers: { authorization: 'Bearer secret-token' },
method: 'DELETE',

Просмотреть файл

@ -1,7 +1,7 @@
import UAParser from 'ua-parser-js';
import * as actions from 'core/actions';
import api from 'core/reducers/api';
import api, { initialApiState } from 'core/reducers/api';
import { signedInApiState, userAgents, userAuthToken }
from 'tests/client/helpers';
@ -20,9 +20,9 @@ describe('api reducer', () => {
});
it('clears the auth token on log out', () => {
const expectedState = { ...signedInApiState };
assert.ok(expectedState.token, 'signed in state did not have a token');
delete expectedState.token;
const state = { ...signedInApiState };
assert.ok(state.token, 'signed in state did not have a token');
const expectedState = { ...state, token: null };
assert.deepEqual(
api(signedInApiState, actions.logOutUser()), expectedState);
});
@ -82,6 +82,7 @@ describe('api reducer', () => {
});
it('defaults to an empty object', () => {
assert.deepEqual(api(undefined, { type: 'UNRELATED' }), {});
assert.deepEqual(
api(undefined, { type: 'UNRELATED' }), { ...initialApiState });
});
});

Просмотреть файл

@ -1,6 +1,7 @@
import createStore from 'amo/store';
import * as searchActions from 'core/actions/search';
import * as api from 'core/api';
import { initialApiState } from 'core/reducers/api';
import { ADDON_TYPE_THEME } from 'core/constants';
import { loadByCategoryIfNeeded, mapStateToProps } from 'core/searchUtils';
@ -52,7 +53,7 @@ describe('searchUtils loadByCategoryIfNeeded()', () => {
mockApi
.expects('search')
.once()
.withArgs({ page: 1, filters, api: {}, auth: {} })
.withArgs({ page: 1, filters, api: { ...initialApiState }, auth: {} })
.returns(Promise.resolve({ entities, result }));
return loadByCategoryIfNeeded({
store,
@ -77,7 +78,7 @@ describe('searchUtils loadByCategoryIfNeeded()', () => {
mockApi
.expects('search')
.once()
.withArgs({ page: 1, filters, api: {}, auth: {} })
.withArgs({ page: 1, filters, api: { ...initialApiState }, auth: {} })
.returns(Promise.resolve({ entities, result }));
return loadByCategoryIfNeeded({
store,

Просмотреть файл

@ -7,6 +7,7 @@ import UAParser from 'ua-parser-js';
import { ADDON_TYPE_EXTENSION } from 'core/constants';
import { makeI18n } from 'core/i18n/utils';
import { initialApiState } from 'core/reducers/api';
/*
* Return a fake authentication token that can be
@ -109,6 +110,7 @@ export function assertNotHasClass(el, className) {
const userAgentForState = 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1';
const { browser, os } = UAParser(userAgentForState);
export const signedInApiState = Object.freeze({
...initialApiState,
lang: 'en-US',
token: 'secret-token',
userAgent: userAgentForState,

Просмотреть файл

@ -107,7 +107,7 @@ ansi-styles@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
anymatch@^1.3.0:
anymatch@^1.1.0, anymatch@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.0.tgz#a3e52fa39168c825ff57b0248126ce5a8ff95507"
dependencies:
@ -149,6 +149,10 @@ array-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
array-filter@~0.0.0:
version "0.0.1"
resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec"
array-find-index@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
@ -157,6 +161,14 @@ array-flatten@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
array-map@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662"
array-reduce@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b"
array-slice@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5"
@ -750,7 +762,7 @@ babel-plugin-transform-exponentiation-operator@^6.22.0:
babel-plugin-syntax-exponentiation-operator "^6.8.0"
babel-runtime "^6.22.0"
babel-plugin-transform-flow-strip-types@^6.3.13:
babel-plugin-transform-flow-strip-types@6.22.0, babel-plugin-transform-flow-strip-types@^6.3.13:
version "6.22.0"
resolved "https://registry.yarnpkg.com/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz#84cb672935d43714fdc32bce84568d87441cf7cf"
dependencies:
@ -1002,6 +1014,10 @@ block-stream@*:
dependencies:
inherits "~2.0.0"
bluebird@^2.9.24:
version "2.11.0"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1"
bluebird@^3.3.0, bluebird@^3.3.1:
version "3.5.0"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c"
@ -1143,7 +1159,7 @@ camelcase@^1.0.2:
version "1.2.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39"
camelcase@^2.0.0:
camelcase@^2.0.0, camelcase@^2.0.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
@ -1240,7 +1256,18 @@ cheerio@0.22.0:
lodash.reject "^4.4.0"
lodash.some "^4.4.0"
chokidar@^1.0.0, chokidar@^1.4.1, chokidar@^1.5.0, chokidar@^1.6.0:
chokidar-cli@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/chokidar-cli/-/chokidar-cli-1.2.0.tgz#8e7f58442273182018be1868e53c22af65a21948"
dependencies:
anymatch "^1.1.0"
bluebird "^2.9.24"
chokidar "^1.0.1"
lodash "^3.7.0"
shell-quote "^1.4.3"
yargs "^3.7.2"
chokidar@^1.0.0, chokidar@^1.0.1, chokidar@^1.4.1, chokidar@^1.5.0, chokidar@^1.6.0:
version "1.6.1"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.6.1.tgz#2f4447ab5e96e50fb3d789fd90d4c72e0e4c70c2"
dependencies:
@ -1287,7 +1314,7 @@ cliui@^2.1.0:
right-align "^0.1.1"
wordwrap "0.0.2"
cliui@^3.2.0:
cliui@^3.0.3, cliui@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d"
dependencies:
@ -2616,6 +2643,10 @@ flatten@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
flow-bin@0.38.0:
version "0.38.0"
resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.38.0.tgz#3ae096d401c969cc8b5798253fb82381e2d0237a"
for-in@^0.1.3:
version "0.1.8"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1"
@ -3973,7 +4004,7 @@ lodash.uniq@^4.3.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
lodash@^3.8.0:
lodash@^3.7.0, lodash@^3.8.0:
version "3.10.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
@ -4622,10 +4653,6 @@ path-is-inside@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
path-parse@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1"
path-to-regexp@0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
@ -5620,16 +5647,10 @@ resolve-from@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57"
resolve@1.1.x:
resolve@1.1.x, resolve@^1.1.6:
version "1.1.7"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
resolve@^1.1.6:
version "1.3.2"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.3.2.tgz#1f0442c9e0cbb8136e87b9305f932f46c7f28235"
dependencies:
path-parse "^1.0.5"
restore-cursor@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
@ -5808,6 +5829,15 @@ shallowequal@^0.2.2:
dependencies:
lodash.keys "^3.1.2"
shell-quote@^1.4.3:
version "1.6.1"
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767"
dependencies:
array-filter "~0.0.0"
array-map "~0.0.0"
array-reduce "~0.0.0"
jsonify "~0.0.0"
shelljs@0.7.7, shelljs@^0.7.5:
version "0.7.7"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.7.tgz#b2f5c77ef97148f4b4f6e22682e10bba8667cff1"
@ -6819,6 +6849,10 @@ window-size@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
window-size@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876"
window-size@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075"
@ -6883,7 +6917,7 @@ xmlhttprequest-ssl@1.5.3:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
y18n@^3.2.1:
y18n@^3.2.0, y18n@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
@ -6908,14 +6942,17 @@ yargs@^1.2.6:
version "1.3.3"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-1.3.3.tgz#054de8b61f22eefdb7207059eaef9d6b83fb931a"
yargs@^3.5.4, yargs@~3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"
yargs@^3.5.4, yargs@^3.7.2:
version "3.32.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.32.0.tgz#03088e9ebf9e756b69751611d2a5ef591482c995"
dependencies:
camelcase "^1.0.2"
cliui "^2.1.0"
decamelize "^1.0.0"
window-size "0.1.0"
camelcase "^2.0.1"
cliui "^3.0.3"
decamelize "^1.1.1"
os-locale "^1.4.0"
string-width "^1.0.1"
window-size "^0.1.4"
y18n "^3.2.0"
yargs@^4.7.1:
version "4.8.1"
@ -6954,6 +6991,15 @@ yargs@^6.0.0:
y18n "^3.2.1"
yargs-parser "^4.2.0"
yargs@~3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"
dependencies:
camelcase "^1.0.2"
cliui "^2.1.0"
decamelize "^1.0.0"
window-size "0.1.0"
yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"