├── core ├── .npmignore ├── src │ ├── redux-api-middleware │ │ ├── redux-api-middleware.d.ts │ │ ├── index.ts │ │ ├── functions.ts │ │ └── README.md │ ├── hydra │ │ ├── index.ts │ │ ├── HydraCollection.ts │ │ ├── README.md │ │ └── redux.ts │ ├── index.ts │ ├── store │ │ ├── index.ts │ │ ├── redux │ │ │ ├── actions.ts │ │ │ ├── functions.ts │ │ │ ├── selectors.ts │ │ │ ├── types.ts │ │ │ └── reducers.ts │ │ ├── Collection.ts │ │ └── README.md │ └── typed-action-creator │ │ ├── factory.ts │ │ └── README.md ├── .npmrc ├── tests │ ├── store │ │ ├── Collection.test.ts │ │ └── redux │ │ │ ├── actions.test.ts │ │ │ ├── reducers.items.test.ts │ │ │ ├── selectors.test.ts │ │ │ ├── functions.test.ts │ │ │ ├── reducers.test.ts │ │ │ └── reducers.list.test.ts │ ├── hydra │ │ ├── collection.test.ts │ │ └── redux.test.ts │ ├── redux-api-middleware │ │ └── functions.test.ts │ └── typed-action-creator │ │ └── factory.test.ts ├── jest.config.js ├── tsconfig.json ├── package.json └── LICENSE ├── .gitignore ├── native ├── .babelrc ├── .npmrc ├── src │ ├── infinite-scroll-view │ │ ├── README.md │ │ ├── DefaultLoadingIndicator.tsx │ │ └── InfiniteScrollView.tsx │ ├── index.ts │ ├── collection │ │ ├── Collection.ts │ │ └── components │ │ │ ├── CollectionComponent.tsx │ │ │ └── ScrollableCollection.tsx │ ├── navigation │ │ └── functions.ts │ ├── errors │ │ ├── middleware.ts │ │ ├── index.ts │ │ ├── components │ │ │ ├── RetryButton.tsx │ │ │ ├── ErrorMessage.tsx │ │ │ └── ErrorWrapper.tsx │ │ ├── saga │ │ │ ├── decorator.ts │ │ │ └── error-handler.ts │ │ ├── store.ts │ │ └── README.md │ ├── empty-state │ │ └── ZeroStatePlaceholder.tsx │ └── loaded-entity │ │ ├── connectEntity.ts │ │ ├── components │ │ └── WaitUntilEntityIsLoadedFactory.tsx │ │ └── README.md ├── tests │ ├── loaded-entity │ │ ├── support │ │ │ ├── actions.ts │ │ │ └── components.tsx │ │ ├── __snapshots__ │ │ │ └── connectEntity.test.tsx.snap │ │ └── connectEntity.test.tsx │ ├── errors │ │ ├── saga │ │ │ ├── error-handler.test.ts │ │ │ └── decorator.test.ts │ │ ├── reducer.test.ts │ │ └── selectors.test.ts │ ├── navigation │ │ └── currentRoute.test.ts │ └── collection │ │ └── components │ │ ├── ScrollableCollection.test.tsx │ │ └── __snapshots__ │ │ └── ScrollableCollection.test.tsx.snap ├── tsconfig.json ├── jest.config.js ├── package.json └── .flowconfig ├── assets └── galette.png ├── .npmrc ├── web ├── .npmrc ├── package.json ├── src │ └── token-wall │ │ ├── components │ │ └── TokenEnforcementWall.tsx │ │ └── store.ts └── package-lock.json ├── DEVELOPMENT.md ├── .github └── workflows │ ├── ci.yml │ └── cd.yml └── README.md /core/.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | src 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .jest/ 4 | .idea 5 | -------------------------------------------------------------------------------- /native/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["module:metro-react-native-babel-preset"] 3 | } 4 | -------------------------------------------------------------------------------- /assets/galette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/birdiecare/galette/HEAD/assets/galette.png -------------------------------------------------------------------------------- /core/src/redux-api-middleware/redux-api-middleware.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'redux-api-middleware'; 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | @birdiecare:registry=https://npm.pkg.github.com/ 3 | -------------------------------------------------------------------------------- /core/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | @birdiecare:registry=https://npm.pkg.github.com/ -------------------------------------------------------------------------------- /native/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | @birdiecare:registry=https://npm.pkg.github.com/ 3 | -------------------------------------------------------------------------------- /web/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | @birdiecare:registry=https://npm.pkg.github.com/ 3 | -------------------------------------------------------------------------------- /core/src/redux-api-middleware/index.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "./functions"; 2 | 3 | export { 4 | functions, 5 | } 6 | -------------------------------------------------------------------------------- /native/src/infinite-scroll-view/README.md: -------------------------------------------------------------------------------- 1 | # InfiniteScrollView 2 | 3 | See https://github.com/expo/react-native-infinite-scroll-view/pull/43. 4 | -------------------------------------------------------------------------------- /native/tests/loaded-entity/support/actions.ts: -------------------------------------------------------------------------------- 1 | export function loadUser(username) { 2 | return { 3 | type: 'LOAD_USER', 4 | username 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@birdiecare/galette-web", 3 | "version": "0.0.1", 4 | "dependencies": { 5 | "react-native-elements": "^0.18.4" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /native/src/index.ts: -------------------------------------------------------------------------------- 1 | import WaitUntilEntityIsLoadedFactory from "./loaded-entity/components/WaitUntilEntityIsLoadedFactory" 2 | import * as errors from "./errors" 3 | 4 | export { 5 | WaitUntilEntityIsLoadedFactory, 6 | errors, 7 | } 8 | -------------------------------------------------------------------------------- /native/src/collection/Collection.ts: -------------------------------------------------------------------------------- 1 | import CoreCollection from '@birdiecare/galette-core/dist/store/Collection' 2 | 3 | export default class Collection extends CoreCollection { 4 | dataSource() { 5 | return this.items() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /native/tests/loaded-entity/support/components.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Text} from "react-native"; 3 | 4 | export const SimpleComponentExpectingAUserEntity: React.FC<{user: { name : string }}> = ({ user }) => 5 | {user.name} 6 | -------------------------------------------------------------------------------- /core/src/hydra/index.ts: -------------------------------------------------------------------------------- 1 | import HydraCollection from "./HydraCollection"; 2 | import { reduceList, reduceItems, reduceListAndItems } from "./redux"; 3 | 4 | export { 5 | HydraCollection, 6 | reduceList, 7 | reduceItems, 8 | reduceListAndItems, 9 | } 10 | -------------------------------------------------------------------------------- /native/src/navigation/functions.ts: -------------------------------------------------------------------------------- 1 | // Get the current route from the navigation state. 2 | export const currentRoute = (navigation) => { 3 | if (navigation.index !== undefined && navigation.routes) { 4 | return currentRoute(navigation.routes[navigation.index]); 5 | } 6 | 7 | return navigation; 8 | }; 9 | -------------------------------------------------------------------------------- /core/tests/store/Collection.test.ts: -------------------------------------------------------------------------------- 1 | import Collection from "../../src/store/Collection"; 2 | 3 | describe('A collection of items', () => { 4 | it('returns an empty array when empty', () => { 5 | const collection = new Collection({}); 6 | 7 | expect(collection.items()).toEqual([]); 8 | }) 9 | }); 10 | -------------------------------------------------------------------------------- /core/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as store from "./store" 2 | import * as hydra from "./hydra" 3 | import * as ram from "./redux-api-middleware" 4 | import typedActionCreatorFactory from "./typed-action-creator/factory"; 5 | 6 | export { 7 | store, 8 | hydra, 9 | ram, 10 | typedActionCreatorFactory, 11 | } 12 | 13 | export * from "./store/redux/types" -------------------------------------------------------------------------------- /native/src/errors/middleware.ts: -------------------------------------------------------------------------------- 1 | import { store } from "@birdiecare/galette-core"; 2 | const {actions} = store; 3 | 4 | const middleware = store => next => action => { 5 | try { 6 | return next(action) 7 | } catch (err) { 8 | next(actions.reportError(err, action)) 9 | 10 | throw err 11 | } 12 | } 13 | 14 | export default middleware; 15 | -------------------------------------------------------------------------------- /core/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Collection from "./Collection"; 2 | 3 | import * as reducers from "./redux/reducers" 4 | import * as functions from "./redux/functions" 5 | import * as selectors from "./redux/selectors" 6 | import * as actions from "./redux/actions" 7 | 8 | export { 9 | Collection, 10 | reducers, 11 | functions, 12 | selectors, 13 | actions, 14 | } 15 | -------------------------------------------------------------------------------- /core/tests/store/redux/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { reportError } from '../../../src/store/redux/actions'; 2 | 3 | describe('Action creators', () => { 4 | it('generates an identifier for the errors', () => { 5 | const action = reportError(new Error('Foo')); 6 | 7 | expect(action.type).toEqual('@Galette/REPORT_ERROR'); 8 | expect(action.identifier).toBeTruthy(); 9 | }) 10 | }); 11 | -------------------------------------------------------------------------------- /core/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | 'ts-jest': { 4 | tsConfigFile: 'tsconfig.json' 5 | } 6 | }, 7 | moduleFileExtensions: [ 8 | 'ts', 9 | 'js' 10 | ], 11 | transform: { 12 | '^.+\\.(ts|tsx)$': './node_modules/ts-jest/preprocessor.js' 13 | }, 14 | testMatch: [ 15 | '**/*.test.(ts|js)' 16 | ], 17 | testEnvironment: 'node' 18 | }; 19 | -------------------------------------------------------------------------------- /core/tests/hydra/collection.test.ts: -------------------------------------------------------------------------------- 1 | import HydraCollection from "../../src/hydra/HydraCollection"; 2 | 3 | describe('An hydra collection', () => { 4 | it('gets items from the hydra:members', () => { 5 | const collection = new HydraCollection({ 6 | 'hydra:member': [ 7 | {id: 1, name: 'Foo'} 8 | ] 9 | }) 10 | 11 | expect(collection.items()).toEqual([ 12 | {id: 1, name: 'Foo'} 13 | ]) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /core/tests/store/redux/reducers.items.test.ts: -------------------------------------------------------------------------------- 1 | import {reduceItems} from "../../../src/store/redux/reducers"; 2 | 3 | describe('Reducers items', function () { 4 | it('does reduces each individual item by their IDs', () => { 5 | const reduced = reduceItems(undefined, {}, { 6 | items: [{id: 1, name: 'Foo'}, {id: 2, name: 'Bar'}], 7 | itemIdentifierResolver: item => item.id 8 | }); 9 | 10 | expect(reduced).toEqual({ 11 | 1: {id: 1, name: 'Foo'}, 12 | 2: {id: 2, name: 'Bar'} 13 | }) 14 | }) 15 | }); 16 | -------------------------------------------------------------------------------- /core/tests/store/redux/selectors.test.ts: -------------------------------------------------------------------------------- 1 | import {collectionWithItems} from "../../../src/store/redux/selectors"; 2 | 3 | describe('Selector for a collection', () => { 4 | it('gets the list with items', () => { 5 | const state = { 6 | list: { 7 | identifiers: [1, 2] 8 | }, 9 | 1: {name: 'Foo'}, 10 | 2: {name: 'Bar'} 11 | }; 12 | 13 | expect(collectionWithItems(state, 'list')).toEqual({ 14 | identifiers: [1, 2], 15 | items: [ 16 | {name: 'Foo'}, 17 | {name: 'Bar'} 18 | ] 19 | }) 20 | }) 21 | }); 22 | -------------------------------------------------------------------------------- /core/src/hydra/HydraCollection.ts: -------------------------------------------------------------------------------- 1 | import Collection, {CollectionStructure} from '../store/Collection'; 2 | 3 | export type HydraResponse = { 4 | "hydra:member"?: any[]; 5 | } 6 | 7 | export function collectionStructureFromHydraResponse(hydraResponse: HydraResponse) : CollectionStructure 8 | { 9 | const items = hydraResponse['hydra:member'] || []; 10 | 11 | return { 12 | items, 13 | }; 14 | } 15 | 16 | export default class HydraCollection extends Collection { 17 | constructor(response: object) { 18 | super(collectionStructureFromHydraResponse(response)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "commonjs", 5 | "allowSyntheticDefaultImports": true, 6 | "esModuleInterop": true, 7 | "lib": ["es7"], 8 | "target": "es5", 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "outDir": "dist", 13 | "baseUrl": ".", 14 | "paths": { 15 | "*": [ 16 | "node_modules/*", 17 | "src/types/*" 18 | ] 19 | }, 20 | "types": ["jest", "node"] 21 | }, 22 | "include": [ 23 | "src/**/*" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /native/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "outDir": "dist", 9 | "moduleResolution": "node", 10 | "baseUrl": ".", 11 | "paths": { 12 | "*": [ 13 | "node_modules/*", 14 | "src/types/*" 15 | ] 16 | }, 17 | "allowSyntheticDefaultImports": true, 18 | "esModuleInterop": true, 19 | "types": ["react", "react-native", "jest"], 20 | "skipLibCheck": true 21 | }, 22 | "include": [ 23 | "src/**/*" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /native/src/infinite-scroll-view/DefaultLoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { 5 | ActivityIndicator, 6 | StyleSheet, 7 | View, 8 | } from 'react-native'; 9 | 10 | export default class DefaultLoadingIndicator extends React.Component { 11 | render() { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | } 19 | 20 | let styles = StyleSheet.create({ 21 | container: { 22 | flex: 1, 23 | padding: 20, 24 | backgroundColor: 'transparent', 25 | justifyContent: 'center', 26 | alignItems: 'center', 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /native/src/empty-state/ZeroStatePlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from "react" 2 | import {Text, View} from "react-native"; 3 | 4 | type Props = { 5 | message: string; 6 | 7 | description?: string; 8 | }; 9 | 10 | export default class ZeroStatePlaceholder extends Component { 11 | render() { 12 | return ( 13 | 14 | {this.props.message} 15 | {this.props.description && ( 16 | {this.props.description} 17 | )} 18 | 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /native/src/errors/index.ts: -------------------------------------------------------------------------------- 1 | import ErrorWrapper from "./components/ErrorWrapper"; 2 | import ErrorMessage from "./components/ErrorMessage"; 3 | import middleware from "./middleware"; 4 | import { reducer, reportedErrors } from "./store"; 5 | import sagaErrorHandler from "./saga/error-handler"; 6 | import handleSagaErrors from "./saga/decorator"; 7 | import {store} from "@birdiecare/galette-core"; 8 | 9 | const {actions} = store; 10 | 11 | const components = { 12 | ErrorWrapper, 13 | ErrorMessage, 14 | } 15 | 16 | const selectors = { 17 | reportedErrors, 18 | } 19 | 20 | export { 21 | components, 22 | middleware, 23 | reducer, 24 | selectors, 25 | sagaErrorHandler, 26 | handleSagaErrors, 27 | actions, 28 | }; 29 | -------------------------------------------------------------------------------- /web/src/token-wall/components/TokenEnforcementWall.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {Component} from "react"; 3 | import {getToken} from "../token"; 4 | 5 | export default class TokenEnforcementWall extends Component { 6 | render() { 7 | if (getToken()) { 8 | return this.props.children; 9 | } 10 | 11 | return ( 12 |
13 |

You need to be authenticated.

14 |
15 |

16 | 17 | 18 |

19 |

20 | 21 |

22 |
23 |
24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /native/src/errors/components/RetryButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import { Text, View, TouchableHighlight } from "react-native" 3 | import { Icon } from 'react-native-elements'; 4 | 5 | type Props = { 6 | onPress: () => void; 7 | } 8 | 9 | export default class RetryButton extends Component 10 | { 11 | render() { 12 | return ( 13 | 14 | 15 | 16 | Retry 17 | 18 | 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /core/src/typed-action-creator/factory.ts: -------------------------------------------------------------------------------- 1 | export type TypedAction = R & { 2 | type: string; 3 | } 4 | 5 | export type ActionCreator = ((...args: A) => TypedAction); 6 | 7 | export type TypedActionCreator = ActionCreator & { 8 | type: string; 9 | } 10 | 11 | export default function typedActionCreatorFactory(type: string, resolver: (...args: A) => R) : TypedActionCreator 12 | { 13 | const actionCreator = (...args: A) => { 14 | const properties = resolver(...args); 15 | return Object.assign(properties, { 16 | type 17 | }) 18 | } 19 | return Object.assign(actionCreator, { 20 | type 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /native/tests/loaded-entity/__snapshots__/connectEntity.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`it displays a loading indicator and dispatches the action if entity was not yet loaded 1`] = ` 4 | 17 | 23 | 24 | `; 25 | 26 | exports[`it renders the component with the right property 1`] = ` 27 | 28 | Samuel Roze 29 | 30 | `; 31 | -------------------------------------------------------------------------------- /core/tests/redux-api-middleware/functions.test.ts: -------------------------------------------------------------------------------- 1 | import { ram } from "../../src"; 2 | const { functions: { createApiCallAction }} = ram; 3 | 4 | describe('redux-api-middleware integration', () => { 5 | it('creates an API call', () => { 6 | const action = createApiCallAction('MY_DOMAIN_ACTION', { 7 | url: 'https://google.com', 8 | method: 'get' 9 | }) 10 | 11 | expect(action).toEqual({ 12 | "@@redux-api-middleware/RSAA": { 13 | "method": "get", 14 | "url": "https://google.com", 15 | "types": [ 16 | {"meta": {}, "type": "MY_DOMAIN_ACTION_SENT"}, 17 | {"meta": {}, "type": "MY_DOMAIN_ACTION_RECEIVED"}, 18 | {"meta": {}, "type": "MY_DOMAIN_ACTION_FAILED"} 19 | ] 20 | } 21 | }) 22 | }) 23 | }); 24 | -------------------------------------------------------------------------------- /native/src/errors/saga/decorator.ts: -------------------------------------------------------------------------------- 1 | import { put } from 'redux-saga/effects' 2 | import { Action } from "redux"; 3 | import {store, ReportActionOptions} from "@birdiecare/galette-core"; 4 | 5 | const {actions: {reportError}} = store; 6 | 7 | export default function handleSagaErrors(generator, options?: ReportActionOptions) { 8 | return function*(action?: Action, ...rest: any[]) { 9 | try { 10 | yield* generator.apply(null, [ action, ...rest ]); 11 | } catch (e) { 12 | let reportErrorOptions = options !== undefined ? { ...options } : undefined; 13 | if (action !== undefined) { 14 | reportErrorOptions = { ...(reportErrorOptions || {}), triggerAction: action }; 15 | } 16 | 17 | yield put(reportError(e, reportErrorOptions)); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/src/store/redux/actions.ts: -------------------------------------------------------------------------------- 1 | import { ReportActionOptions, ReportErrorActionCreatorWithType, DismissErrorActionCreatorWithType } from "./types"; 2 | import typedActionCreatorFactory from "../../typed-action-creator/factory"; 3 | 4 | const randomIdentifier = () => Math.random().toString(36).substring(2, 15) 5 | 6 | export const reportError : ReportErrorActionCreatorWithType = typedActionCreatorFactory( 7 | '@Galette/REPORT_ERROR', 8 | (error: Error, options?: ReportActionOptions, identifier?: string) => ({ 9 | error, 10 | options, 11 | 12 | identifier: identifier || randomIdentifier(), 13 | }) 14 | ); 15 | 16 | export const dismissError : DismissErrorActionCreatorWithType = typedActionCreatorFactory( 17 | '@Galette/DISMISS_ERROR', 18 | (identifier: string) => ({ 19 | identifier, 20 | }) 21 | ); 22 | -------------------------------------------------------------------------------- /core/src/store/redux/functions.ts: -------------------------------------------------------------------------------- 1 | import {AnyAction} from "redux"; 2 | 3 | export const updateItem = (state : any = {}, itemIdentifier : string, itemState : any) : any => { 4 | return { 5 | ...state, 6 | [itemIdentifier]: { 7 | ...(state[itemIdentifier] || {}), 8 | ...itemState 9 | } 10 | } 11 | }; 12 | 13 | type AnyReducer = (state: StateType, action: AnyAction) => StateType; 14 | type ReducerMapping = { 15 | [type: string]: AnyReducer; 16 | }; 17 | 18 | export function createMappedReducer(initialState : StateType, mapping : ReducerMapping) { 19 | return (state : StateType = initialState, action : any) => { 20 | if (action.type in mapping) { 21 | return mapping[action.type](state, action); 22 | } 23 | 24 | return state; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /native/src/errors/saga/error-handler.ts: -------------------------------------------------------------------------------- 1 | import {store} from "@birdiecare/galette-core"; 2 | const {actions: {reportError}} = store; 3 | 4 | type Store = { 5 | dispatch: (action: any) => any; 6 | } 7 | 8 | type ErrorHandler = (error: Error) => void; 9 | type ErrorHandlerWithStoreSetter = ErrorHandler & { 10 | setStore: (store: Store) => void; 11 | } 12 | 13 | let _store = null; 14 | const sagaErrorHandler : ErrorHandler = (error: Error) => { 15 | if (!_store) { 16 | throw new Error('You need to set the store on the Saga error handler using the `setStore` method.'); 17 | } 18 | 19 | _store.dispatch(reportError(error)); 20 | } 21 | 22 | const sagaErrorHandlerWithSetter : ErrorHandlerWithStoreSetter = Object.assign(sagaErrorHandler, { 23 | setStore: store => _store = store, 24 | }) 25 | 26 | export default sagaErrorHandlerWithSetter; 27 | -------------------------------------------------------------------------------- /web/src/token-wall/store.ts: -------------------------------------------------------------------------------- 1 | const getParams = query => { 2 | if (!query) { 3 | return {}; 4 | } 5 | 6 | return (/^[?#]/.test(query) ? query.slice(1) : query) 7 | .split('&') 8 | .reduce((params, param) => { 9 | let [key, value] = param.split('='); 10 | params[key] = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : ''; 11 | return params; 12 | }, {}); 13 | }; 14 | 15 | export const getToken = (): string => { 16 | let token: string = localStorage.getItem('token'); 17 | if (token) { 18 | return token; 19 | } 20 | 21 | token = getParams(document.location.search).access_token; 22 | if (token) { 23 | localStorage.setItem('token', token); 24 | 25 | // Refresh so that it looses the token 26 | window.location.href = window.location.origin; 27 | } 28 | 29 | return token; 30 | } 31 | -------------------------------------------------------------------------------- /native/tests/errors/saga/error-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { errors } from '../../../src' 2 | const { sagaErrorHandler, actions: { reportError } } = errors; 3 | 4 | describe('Error handler for the saga', () => { 5 | it('throws an error if store was not set', () => { 6 | sagaErrorHandler.setStore(null); 7 | expect(() => sagaErrorHandler(new Error('Oups.'))).toThrow(Error); 8 | }) 9 | 10 | it('dispatches an action', () => { 11 | const dispatchedActions = []; 12 | 13 | sagaErrorHandler.setStore({ 14 | dispatch: action => dispatchedActions.push(action) 15 | }) 16 | 17 | sagaErrorHandler(new Error('Oups.')); 18 | 19 | expect(dispatchedActions.length).toBe(1); 20 | expect(dispatchedActions[0].type).toBe('@Galette/REPORT_ERROR'); 21 | expect(dispatchedActions[0].error).toEqual(new Error('Oups.')); 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /core/tests/store/redux/functions.test.ts: -------------------------------------------------------------------------------- 1 | import {updateItem} from "../../../src/store/redux/functions"; 2 | 3 | describe('Update an item of the state', () => { 4 | it('returns an object even from undefined', () => { 5 | expect(updateItem(undefined, "1234", {foo: 'bar'})).toEqual({ 6 | "1234": { 7 | foo: 'bar' 8 | } 9 | }) 10 | }); 11 | 12 | it('creates an item', () => { 13 | expect( 14 | updateItem({}, "1234", {some: 'thing'}) 15 | ).toEqual({ 16 | "1234": { 17 | some: 'thing' 18 | } 19 | }); 20 | }); 21 | 22 | it('updates an item without erasing', () => { 23 | expect( 24 | updateItem({ 25 | "1234": { 26 | first: 'thing' 27 | } 28 | }, "1234", { 29 | another: 'one' 30 | }) 31 | ).toEqual({ 32 | "1234": { 33 | first: 'thing', 34 | another: 'one' 35 | } 36 | }); 37 | }) 38 | }); 39 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Working on the library 4 | 5 | The best option is to install Galette on your project(s) and symlink the project's `node_modules/@birdiecare/galette-[package]` to 6 | a local clone of the Galette Git repository. 7 | 8 | 1. Clone Galette. 9 | 10 | ``` 11 | git clone git@github.com:kametventures/galette.git 12 | ``` 13 | 14 | 2. Go on your project's directory. (We assume Galette is already a dependency) 15 | 16 | ``` 17 | cd node_modules/@birdiecare/galette 18 | 19 | rm -rf core && ln -s /path/to/galette/git/core core 20 | ``` 21 | 22 | **Important:** replace `/path/to/galette/git` in the example above with the path on your own machine. 23 | 24 | 3. Start Galette's watcher so that your updates in the `core/src` directory are compiled and available for your 25 | project. 26 | 27 | ``` 28 | cd /path/to/galette/git/core 29 | 30 | npm install 31 | npm run build --watch 32 | ``` 33 | -------------------------------------------------------------------------------- /core/src/hydra/README.md: -------------------------------------------------------------------------------- 1 | # Hydra 2 | 3 | The [Hydra specification](https://www.w3.org/community/hydra/) is trying to normalise 4 | APIs and starts to get some traction. For example, the [API Platform framework](https://api-platform.com/) 5 | allows you to expose an API following the Hydra specification in minutes. 6 | 7 | This module is a super-set of the [Store module](../store#readme) to provide default 8 | payload resolvers. 9 | 10 | ## Usage 11 | 12 | ```javascript 13 | 14 | import { hydra } from "@birdiecare/galette-core"; 15 | const { reduceListAndItems, reduceItems, reduceList } = hydra; 16 | 17 | ``` 18 | 19 | Use these functions as you'd the ones coming from the [Store module](../store#readme) 20 | but you DO NOT need the following options: 21 | 22 | - `itemIdentifierResolver`. Get the identifier from the `@id` item. 23 | - `totalItems`. Get them from the `hydra:totalItems` response payload. 24 | - `items`. Gets them from the `hydra:member` response payload. 25 | -------------------------------------------------------------------------------- /core/src/store/redux/selectors.ts: -------------------------------------------------------------------------------- 1 | import {ReducedList} from "./reducers"; 2 | 3 | const defaultList : ReducedList = { 4 | identifiers: [], 5 | }; 6 | 7 | export type ReducedListWithItems = ReducedList & { 8 | items: any[]; 9 | } 10 | 11 | export type SelectorOptions = { 12 | itemResolver?: (identifier: string) => any; 13 | } 14 | 15 | export const collectionWithItems = (state : any, listKeyInState : string, options : SelectorOptions = {}) : ReducedListWithItems => { 16 | let list : ReducedListWithItems = { 17 | ...defaultList, 18 | ...state[listKeyInState] 19 | }; 20 | 21 | list.items = list.identifiers.map((identifier : string) => { 22 | const item = options.itemResolver ? options.itemResolver(identifier) : state[identifier]; 23 | 24 | if (!item) { 25 | console.warn('Could not find item ', identifier); 26 | 27 | return undefined; 28 | } 29 | 30 | return item; 31 | }).filter((item : any) => item !== undefined); 32 | 33 | return list; 34 | }; 35 | -------------------------------------------------------------------------------- /core/tests/typed-action-creator/factory.test.ts: -------------------------------------------------------------------------------- 1 | import { typedActionCreatorFactory } from "../../src/index"; 2 | 3 | describe('Typed action', () => { 4 | it('returns an action creator for the given type', () => { 5 | const loadUser = typedActionCreatorFactory('LOAD_USER', user => ({ user })); 6 | 7 | expect(loadUser({ username: 'samuel' })).toEqual({ 8 | type: 'LOAD_USER', 9 | user: { 10 | username: 'samuel' 11 | } 12 | }) 13 | }) 14 | 15 | it('exposes the type as a property of the creator', () => { 16 | const loadUser = typedActionCreatorFactory('LOAD_USER', user => ({ user })); 17 | 18 | expect(loadUser.type).toEqual('LOAD_USER'); 19 | }) 20 | 21 | it('supports multiple arguments', () => { 22 | const loadTravels = typedActionCreatorFactory('LOAD_TRAVELS', (location, page) => ({ location, page })); 23 | 24 | expect(loadTravels('Rennes', 2)).toEqual({ 25 | type: 'LOAD_TRAVELS', 26 | location: 'Rennes', 27 | page: 2 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@birdiecare/galette-core", 3 | "version": "1.0.2", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "files": [ 7 | "dist" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/birdiecare/galette", 12 | "directory": "core" 13 | }, 14 | "scripts": { 15 | "build": "npm run build-ts", 16 | "test": "jest --verbose", 17 | "build-ts": "tsc", 18 | "release": "npm run build && npm publish" 19 | }, 20 | "dependencies": { 21 | "redux": "^4.0.5" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^23.3.10", 25 | "@types/node": "^10.7.1", 26 | "husky": "^3.0.9", 27 | "jest": "^23.4.1", 28 | "prettier": "^1.19.1", 29 | "pretty-quick": "^2.0.1", 30 | "redux-api-middleware": "^2.3.0", 31 | "ts-jest": "^22.4.6", 32 | "ts-node": "^6.1.1", 33 | "typescript": "^3.2.1" 34 | }, 35 | "husky": { 36 | "hooks": { 37 | "pre-commit": "pretty-quick --staged" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /core/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Samuel Roze 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /native/jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { defaults: tsjPreset } = require('ts-jest/presets'); 3 | 4 | 5 | module.exports = { 6 | ...tsjPreset, 7 | preset: 'react-native', 8 | transform: { 9 | ...(tsjPreset.transform || {}), 10 | '\\.js$': '/node_modules/react-native/jest/preprocessor.js', 11 | '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': 12 | '/node_modules/react-native/jest/assetFileTransformer.js', 13 | }, 14 | globals: { 15 | 'ts-jest': { 16 | babelConfig: true, 17 | }, 18 | }, 19 | cacheDirectory: '.jest/cache', 20 | transformIgnorePatterns: [ 21 | // Override default transformIgnorePatterns to not omit node_modules 22 | // Necessary to transform native libraries 23 | ], 24 | testPathIgnorePatterns: [ 25 | '\\.snap$', 26 | '/lib/', 27 | '/node_modules/', 28 | ], 29 | watchPathIgnorePatterns: ['/node_modules/', '/\\.\\w*/'], 30 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 31 | coverageDirectory: '../coverage', 32 | }; 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /core/src/store/Collection.ts: -------------------------------------------------------------------------------- 1 | export type CollectionStructure = { 2 | items?: any[]; 3 | error?: string; 4 | loading?: boolean; 5 | 6 | up_to_page?: number; 7 | total_items?: number; 8 | }; 9 | 10 | export type CollectionItemMutator = (item : any) => any; 11 | 12 | export default class Collection 13 | { 14 | constructor(private structure: CollectionStructure) 15 | { 16 | } 17 | 18 | items() { 19 | return this.structure.items || [] 20 | } 21 | 22 | filter(callback : CollectionItemMutator) { 23 | return new Collection({ 24 | ...this.structure, 25 | items: this.items().filter(callback), 26 | }); 27 | } 28 | 29 | map(callback : CollectionItemMutator) { 30 | return new Collection({ 31 | ...this.structure, 32 | items: this.items().map(callback), 33 | }); 34 | } 35 | 36 | hasError() { 37 | return !!this.structure.error; 38 | } 39 | 40 | isLoading() { 41 | return this.structure.loading || false; 42 | } 43 | 44 | getPage() { 45 | return this.structure.up_to_page; 46 | } 47 | 48 | hasMore() { 49 | if (this.structure.total_items === undefined) { 50 | return true; 51 | } 52 | 53 | return this.structure.total_items > this.items().length; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /core/src/store/redux/types.ts: -------------------------------------------------------------------------------- 1 | import { Action } from "redux"; 2 | 3 | export type Error = { 4 | message: string; 5 | }; 6 | 7 | // Actions 8 | export type ReportedError = { 9 | // The unique identifier of the error 10 | identifier: string; 11 | 12 | // The error 13 | message: string; 14 | } & ReportActionOptions; 15 | 16 | export type ReportActionOptions = { 17 | triggerAction?: Action; 18 | channel?: string; 19 | message?: string; 20 | skipDisplay?: boolean; 21 | skipCapture?: boolean; 22 | } 23 | 24 | // State 25 | export type ErrorModuleState = { 26 | reportedErrors: ReportedError[]; 27 | } 28 | 29 | export type ReportErrorAction = Action & { 30 | error: Error; 31 | identifier: string; 32 | 33 | options?: ReportActionOptions; 34 | } 35 | 36 | type ReportErrorActionCreator = (error: Error, options?: ReportActionOptions, identifier?: string) => ReportErrorAction; 37 | export type ReportErrorActionCreatorWithType = ReportErrorActionCreator & { type: string }; 38 | 39 | export type DismissErrorAction = Action & { 40 | identifier: string; 41 | } 42 | 43 | type DismissErrorActionCreator = (identifier: string) => DismissErrorAction; 44 | export type DismissErrorActionCreatorWithType = DismissErrorActionCreator & { type: string }; 45 | -------------------------------------------------------------------------------- /native/tests/errors/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { reducer, actions } from '../../src/errors' 2 | const {reportError, dismissError} = actions; 3 | 4 | describe('Error reducer', () => { 5 | it('stores reported errors', () => { 6 | const state = reducer(undefined, reportError(new Error('Foo'))); 7 | 8 | expect(state.reportedErrors).toBeTruthy(); 9 | expect(state.reportedErrors.length).toBe(1); 10 | expect(state.reportedErrors[0].message).toBe('Foo'); 11 | }) 12 | 13 | it('remove dismissed errors', () => { 14 | const state = reducer({ 15 | // @ts-ignore 16 | somethingElse: 'foo', 17 | reportedErrors: [ 18 | {identifier: '0978azerty', message: 'To be investigated'}, 19 | {identifier: '1234qwerty', message: 'Oups.'} 20 | ] 21 | }, dismissError('1234qwerty')) 22 | 23 | expect(state).toEqual({ 24 | somethingElse: 'foo', 25 | reportedErrors: [ 26 | {identifier: '0978azerty', message: 'To be investigated'}, 27 | ] 28 | }) 29 | }) 30 | 31 | it('stores the error options such as the channel', () => { 32 | const state = reducer(undefined, reportError(new Error('Foo'), { channel: 'foo' })); 33 | 34 | expect(state.reportedErrors).toBeTruthy(); 35 | expect(state.reportedErrors.length).toBe(1); 36 | expect(state.reportedErrors[0].channel).toBe('foo'); 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /native/src/loaded-entity/connectEntity.ts: -------------------------------------------------------------------------------- 1 | import {bindActionCreators} from "redux"; 2 | import {connect} from "react-redux"; 3 | import WaitUntilEntityIsLoadedFactory from "./components/WaitUntilEntityIsLoadedFactory"; 4 | 5 | type Action = any; 6 | type ConnectEntityOptions = { 7 | property: string; 8 | loadEntityAction: (identifier: string) => Action; 9 | entitySelector: (state: any, identifier: string) => any; 10 | identifierFromPropsResolver: (props: any) => string; 11 | } 12 | 13 | export default function connectEntity(DecoratedComponent, options : ConnectEntityOptions) 14 | { 15 | return connect((state, props) => { 16 | let identifier = options.identifierFromPropsResolver(props); 17 | 18 | return { 19 | [options.property]: options.entitySelector(state, identifier), 20 | } 21 | }, dispatch => { 22 | return bindActionCreators({ 23 | loadEntity: options.loadEntityAction, 24 | }, dispatch); 25 | })(WaitUntilEntityIsLoadedFactory(DecoratedComponent, { 26 | havePropertiesChanged: (prevProps, newProps) => { 27 | return prevProps[options.property] !== newProps[options.property]; 28 | }, 29 | loadObject: (props) => { 30 | props.loadEntity( 31 | options.identifierFromPropsResolver(props) 32 | ) 33 | }, 34 | objectIsLoaded: (props) => { 35 | return !!props[options.property]; 36 | } 37 | })); 38 | }; 39 | -------------------------------------------------------------------------------- /native/src/errors/store.ts: -------------------------------------------------------------------------------- 1 | import { store, ReportErrorAction, DismissErrorAction, ErrorModuleState } from "@birdiecare/galette-core"; 2 | 3 | const {actions: {reportError, dismissError } } = store; 4 | const { functions: { createMappedReducer } } = store; 5 | 6 | const defaultState : ErrorModuleState = { 7 | reportedErrors: [], 8 | }; 9 | 10 | export const reportedErrors = (state: ErrorModuleState & any = defaultState, channel?: string) => { 11 | const errors = state.reportedErrors || []; 12 | 13 | if (channel === null) { 14 | return errors; 15 | } 16 | 17 | return errors.filter(error => error.channel === channel); 18 | }; 19 | 20 | export const reducer = createMappedReducer(defaultState, { 21 | [reportError.type]: (state: ErrorModuleState, action: ReportErrorAction) => { 22 | const reportedError = { 23 | identifier: action.identifier, 24 | message: action.error.message, 25 | ...(action.options || {}) 26 | }; 27 | 28 | return { 29 | ...state, 30 | reportedErrors: [ ...reportedErrors(state, null), reportedError ] 31 | } 32 | }, 33 | 34 | [dismissError.type]: (state: ErrorModuleState, action: DismissErrorAction) => { 35 | const errors = reportedErrors(state, null).filter( 36 | reportedError => reportedError.identifier !== action.identifier 37 | ); 38 | 39 | return { 40 | ...state, 41 | reportedErrors: errors, 42 | } 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | run-tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | 12 | - name: Use Node.js 12.x 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 12.x 16 | registry-url: https://npm.pkg.github.com/ 17 | scope: '@birdiecare' 18 | 19 | - name: Install common dependencies 20 | run: | 21 | echo "//npm.pkg.github.com/:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc 22 | env: 23 | NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }} 24 | NODE_ENV: development 25 | 26 | - name: Run core tests & build for native tests 27 | working-directory: core 28 | run: | 29 | echo "//npm.pkg.github.com/:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc 30 | npm ci 31 | npm test 32 | npm run build 33 | env: 34 | NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }} 35 | NODE_ENV: development 36 | 37 | - name: Native Tests 38 | working-directory: native 39 | run: | 40 | echo "//npm.pkg.github.com/:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc 41 | npm ci 42 | npm test 43 | env: 44 | NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }} 45 | NODE_ENV: development 46 | -------------------------------------------------------------------------------- /core/src/typed-action-creator/README.md: -------------------------------------------------------------------------------- 1 | # Typed Action 2 | 3 | `export`ing action types and action creators, that's enough! This "typed action creator" 4 | factory will allow you to refer to the type from the action creator directly. 5 | 6 | ## Usage 7 | 8 | 1. Create your action creator. 9 | ```javascript 10 | // actions.js 11 | 12 | import { typedActionCreatorFactory } from '@birdiecare/galette-core'; 13 | 14 | export const loadUser = typedActionCreatorFactory('LOAD_USER', username => ({ username })); 15 | ``` 16 | 17 | 2. Uses your action type from the creator 18 | ```javascript 19 | // reducers.js 20 | import { loadUser } from './actions'; 21 | 22 | export const someReducer = (state, action) => { 23 | if (action.type === loadUser.type) { 24 | // do something... 25 | } 26 | } 27 | ``` 28 | 29 | 3. Enjoy! 30 | 31 | ## Reference: Action properties 32 | 33 | The 2nd parameter of the `typedActionCreatorFactory` is the function that will 34 | transform the arguments given to the action creator to the properties within the 35 | action. 36 | 37 | ```javascript 38 | const loadTravels = typedActionCreatorFactory('LOAD_TRAVELS', (location, page = 1) => { 39 | return { 40 | location, 41 | page, 42 | } 43 | }); 44 | 45 | const action = loadTravels('London', 2); 46 | 47 | expect(action).toEqual({ 48 | type: 'LOAD_TRAVELS', 49 | location: 'Rennes', 50 | page: 2 51 | }) 52 | ``` 53 | -------------------------------------------------------------------------------- /core/src/hydra/redux.ts: -------------------------------------------------------------------------------- 1 | import { 2 | reduceList as originalReduceList, 3 | reduceItems as originalReduceItems, 4 | reduceListAndItems as originalReduceListAndItems, 5 | ReduceListOptions, ActionLifecycleOptions, 6 | } from "../store/redux/reducers"; 7 | 8 | import {Action} from "redux"; 9 | 10 | export type HydraOptions = ActionLifecycleOptions & { 11 | listKeyInState: string; 12 | payloadResolver: (action : Action) => any; 13 | } 14 | 15 | const addDefaultHydraOptions = (options : HydraOptions) : ReduceListOptions => { 16 | const {payloadResolver, ...rest} = options; 17 | 18 | return { 19 | itemIdentifierResolver: (item: any) => { 20 | return item['@id']; 21 | }, 22 | items: (action: Action) => { 23 | return payloadResolver(action)['hydra:member'] || []; 24 | }, 25 | totalItems: (action: Action) => { 26 | return payloadResolver(action)['hydra:totalItems']; 27 | }, 28 | ...rest 29 | } 30 | }; 31 | 32 | export function reduceList(state : object, action : Action, options : HydraOptions) { 33 | return originalReduceList(state, action, addDefaultHydraOptions(options)); 34 | } 35 | 36 | export function reduceItems(state : object, action : Action, options : HydraOptions) { 37 | return originalReduceItems(state, action, addDefaultHydraOptions(options)); 38 | } 39 | 40 | export function reduceListAndItems(state : object, action : Action, options : HydraOptions) { 41 | return originalReduceListAndItems(state, action, addDefaultHydraOptions(options)) 42 | } 43 | -------------------------------------------------------------------------------- /native/tests/errors/selectors.test.ts: -------------------------------------------------------------------------------- 1 | import { errors } from '../../src' 2 | const { selectors : { reportedErrors } } = errors; 3 | 4 | describe('Select reported errors', () => { 5 | it('returns an array in any case', () => { 6 | expect(reportedErrors()).toEqual([]); 7 | }) 8 | 9 | it('returns the reported errors', () => { 10 | const state = { 11 | reportedErrors: [ 12 | {identifier: '1234', message: 'Foo'} 13 | ] 14 | }; 15 | 16 | expect(reportedErrors(state)).toEqual([ 17 | {identifier: '1234', message: 'Foo'} 18 | ]) 19 | }) 20 | }); 21 | 22 | describe('Select errors based on channels', () => { 23 | const state = { 24 | reportedErrors: [ 25 | {identifier: '1234', message: 'Foo'}, 26 | {identifier: '5678', message: 'Bar', channel: 'pictureUploader'}, 27 | {identifier: '9012', message: 'Baz', channel: 'pictureUploader'}, 28 | ] 29 | }; 30 | 31 | it('returns non-channelled messages by default', () => { 32 | expect(reportedErrors(state)).toEqual([ 33 | {identifier: '1234', message: 'Foo'}, 34 | ]) 35 | }) 36 | 37 | it('returns the errors of just a single channel', () => { 38 | expect(reportedErrors(state, 'pictureUploader')).toEqual([ 39 | {identifier: '5678', message: 'Bar', channel: 'pictureUploader'}, 40 | {identifier: '9012', message: 'Baz', channel: 'pictureUploader'}, 41 | ]) 42 | }) 43 | 44 | it('can return all the messages when channel is null', () => { 45 | expect(reportedErrors(state, null)).toEqual(state.reportedErrors); 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /native/tests/navigation/currentRoute.test.ts: -------------------------------------------------------------------------------- 1 | import { currentRoute } from "../../src/navigation/functions"; 2 | 3 | test('it gets the current route from a stack', () => { 4 | const navigation = {"key":"StackRouterRoot","isTransitioning":false,"index":1,"routes":[{"routeName":"Authentication","key":"id-1530096560424-0"},{"key":"id-1530096560424-3","isTransitioning":false,"index":2,"routes":[{"routeName":"Eula","key":"id-1530096560424-1"},{"routes":[{"key":"TravelList","routeName":"TravelList"},{"key":"TravelExplore","routeName":"TravelExplore"}],"index":0,"isTransitioning":false,"routeName":"Travels","key":"id-1530096560424-2"},{"routeName":"TravelCreate","key":"id-1530096560424-4"}],"routeName":"MainNavigator"}]}; 5 | 6 | const route = currentRoute(navigation); 7 | 8 | expect(route).toEqual({ 9 | "routeName":"TravelCreate", 10 | "key":"id-1530096560424-4" 11 | }); 12 | }); 13 | 14 | test('it gets the route after a back action', () => { 15 | const navigation = {"key":"StackRouterRoot","isTransitioning":false,"index":1,"routes":[{"routeName":"Authentication","key":"id-1530096560424-0"},{"key":"id-1530096560424-3","isTransitioning":false,"index":1,"routes":[{"routeName":"Eula","key":"id-1530096560424-1"},{"routes":[{"key":"TravelList","routeName":"TravelList"},{"key":"TravelExplore","routeName":"TravelExplore"}],"index":0,"isTransitioning":false,"routeName":"Travels","key":"id-1530096560424-2"}],"routeName":"MainNavigator"}]}; 16 | 17 | const route = currentRoute(navigation); 18 | 19 | expect(route).toEqual({ 20 | "key":"TravelList", 21 | "routeName":"TravelList" 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /native/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@birdiecare/galette-native", 3 | "version": "1.0.5", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "files": [ 7 | "dist" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/birdiecare/galette", 12 | "directory": "native" 13 | }, 14 | "scripts": { 15 | "release": "npm run build && npm publish", 16 | "test": "jest", 17 | "build": "npm run build-ts", 18 | "build-ts": "tsc" 19 | }, 20 | "dependencies": { 21 | "@birdiecare/galette-core": "^1.0.2", 22 | "react-clone-referenced-element": "^1.1.0", 23 | "react-native-elements": "^0.19.1", 24 | "react-native-scrollable-mixin": "^1.0.1", 25 | "react-native-vector-icons": "^7.1.0", 26 | "react-redux": "^5.0.7", 27 | "redux": "^4.0.5", 28 | "redux-mock-store": "^1.5.4" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^26.0.14", 32 | "@types/react-native": "^0.63.20", 33 | "@types/react-test-renderer": "^16.9.3", 34 | "@types/react": "^16.9.49", 35 | "babel-jest": "26.3.0", 36 | "babel-preset-react-native": "4.0.1", 37 | "enzyme-adapter-react-16": "^1.15.4", 38 | "enzyme": "^3.11.0", 39 | "husky": "4.3.0", 40 | "jest": "26.4.2", 41 | "pretty-quick": "^3.0.2", 42 | "react-dom": "^16.13.1", 43 | "react-native-typescript-transformer": "^1.2.13", 44 | "react-native": "0.63.2", 45 | "react-test-renderer": "^16.13.1", 46 | "react": "^16.13.1", 47 | "redux-api-middleware": "^3.2.1", 48 | "redux-saga": "^1.1.3", 49 | "ts-jest": "^26.4.0", 50 | "typescript": "^4.0.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /core/tests/hydra/redux.test.ts: -------------------------------------------------------------------------------- 1 | import {reduceListAndItems} from "../../src/hydra/redux"; 2 | 3 | describe('Reducing a list & items', () => { 4 | const action = { 5 | type: 'SOMETHING_RECEIVED', 6 | payload: { 7 | 'hydra:member': [ 8 | {'@id': '/users/1', id: '1', name: 'Sam'}, 9 | {'@id': '/users/2', id: '2', name: 'Al'}, 10 | ] 11 | } 12 | }; 13 | 14 | it('gets the items from the response', () => { 15 | const state = reduceListAndItems({}, action, { 16 | payloadResolver: (action) => action.payload, 17 | actionPrefix: "SOMETHING", 18 | listKeyInState: "list", 19 | }); 20 | 21 | expect(state).toEqual({ 22 | "/users/1": {"@id": "/users/1", "id": "1", "name": "Sam"}, 23 | "/users/2": {"@id": "/users/2", "id": "2", "name": "Al"}, 24 | "list": { 25 | "identifiers": ["/users/1", "/users/2"], 26 | "loading": false, 27 | "total_items": undefined, 28 | "up_to_page": undefined 29 | } 30 | }) 31 | }) 32 | 33 | it('allow to override options', () => { 34 | const state = reduceListAndItems({}, action, { 35 | payloadResolver: (action) => action.payload, 36 | actionPrefix: "SOMETHING", 37 | listKeyInState: "list", 38 | itemIdentifierResolver: item => item.id, 39 | }); 40 | 41 | expect(state).toEqual({ 42 | "1": {"@id": "/users/1", "id": "1", "name": "Sam"}, 43 | "2": {"@id": "/users/2", "id": "2", "name": "Al"}, 44 | "list": { 45 | "identifiers": ["1", "2"], 46 | "loading": false, 47 | "total_items": undefined, 48 | "up_to_page": undefined 49 | } 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /core/src/redux-api-middleware/functions.ts: -------------------------------------------------------------------------------- 1 | let CALL_API : string; 2 | try { 3 | CALL_API = require("redux-api-middleware").CALL_API; 4 | } catch (e) { 5 | CALL_API = 'MODULE_NOT_INSTALLED'; 6 | } 7 | 8 | import {Action} from "redux"; 9 | import { createMappedReducer } from "../store/redux/functions"; 10 | 11 | // BC-layer: start 12 | // Backward compatible layer for the `createReducer` function moved to the store module. 13 | 14 | export type ReducerHandler = (state : object, action: Action) => any; 15 | export type ReducerHandlers = { 16 | [actionName: string]: ReducerHandler; 17 | } 18 | 19 | export const createReducer = createMappedReducer; 20 | // BC-layer end 21 | 22 | export function createApiCallReducer(actionName: string) { 23 | return createReducer({ 24 | result: null, 25 | error: null, 26 | loading: false 27 | }, { 28 | [actionName+"_SENT"](state) { 29 | return { ...state, loading: true, error: null } 30 | }, 31 | [actionName+"_RECEIVED"](state, action) { 32 | return { ...state, loading: false, result: action.payload, error: null } 33 | }, 34 | [actionName+"_FAILED"](state, action) { 35 | return { ...state, loading: false, error: action.payload } 36 | }, 37 | }) 38 | } 39 | 40 | export function createApiCallAction(actionName : string, call : object, meta : object = {}) { 41 | return { 42 | [CALL_API]: { 43 | ...call, 44 | types: [ 45 | {type: actionName+"_SENT", meta}, 46 | {type: actionName+"_RECEIVED", meta}, 47 | {type: actionName+"_FAILED", meta} 48 | ] 49 | } 50 | } 51 | } 52 | 53 | export function payloadResolver(action : any) { 54 | return action.payload || {}; 55 | } 56 | -------------------------------------------------------------------------------- /native/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | ; We fork some components by platform 3 | .*/*[.]android.js 4 | 5 | ; Ignore "BUCK" generated dirs 6 | /\.buckd/ 7 | 8 | ; Ignore unexpected extra "@providesModule" 9 | .*/node_modules/.*/node_modules/fbjs/.* 10 | 11 | ; Ignore duplicate module providers 12 | ; For RN Apps installed via npm, "Libraries" folder is inside 13 | ; "node_modules/react-native" but in the source repo it is in the root 14 | .*/Libraries/react-native/React.js 15 | 16 | ; Ignore polyfills 17 | .*/Libraries/polyfills/.* 18 | 19 | ; Ignore metro 20 | .*/node_modules/metro/.* 21 | 22 | [include] 23 | 24 | [libs] 25 | node_modules/react-native/Libraries/react-native/react-native-interface.js 26 | node_modules/react-native/flow/ 27 | node_modules/react-native/flow-github/ 28 | 29 | [options] 30 | emoji=true 31 | 32 | module.system=haste 33 | 34 | munge_underscores=true 35 | 36 | module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> 'RelativeImageStub' 37 | 38 | module.file_ext=.js 39 | module.file_ext=.jsx 40 | module.file_ext=.json 41 | module.file_ext=.native.js 42 | 43 | suppress_type=$FlowIssue 44 | suppress_type=$FlowFixMe 45 | suppress_type=$FlowFixMeProps 46 | suppress_type=$FlowFixMeState 47 | 48 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\) 49 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+ 50 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy 51 | suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError 52 | 53 | [version] 54 | ^0.65.0 55 | -------------------------------------------------------------------------------- /native/tests/errors/saga/decorator.test.ts: -------------------------------------------------------------------------------- 1 | import { runSaga } from 'redux-saga' 2 | import { put } from 'redux-saga/effects' 3 | 4 | import { errors } from '../../../src' 5 | const { handleSagaErrors } = errors; 6 | 7 | describe('Saga decorator', () => { 8 | it('reports an error', () => { 9 | const dispatched = []; 10 | const saga = runSaga({ 11 | dispatch: (action) => dispatched.push(action), 12 | getState: () => ({}), 13 | }, handleSagaErrors(function*() { 14 | throw new Error('Oups.') 15 | })); 16 | 17 | expect(dispatched.length).toBe(1); 18 | expect(dispatched[0].type).toBe('@Galette/REPORT_ERROR'); 19 | expect(dispatched[0].error).toEqual(new Error('Oups.')); 20 | }); 21 | 22 | it('forwards the saga arguments', () => { 23 | const dispatched = []; 24 | const saga = runSaga({ 25 | dispatch: (action) => dispatched.push(action), 26 | getState: () => ({}), 27 | }, handleSagaErrors(function*(foo, bar) { 28 | yield put({ type: 'YAY', foo, bar }) 29 | // @ts-ignore 30 | }), 'one', 'two'); 31 | 32 | expect(dispatched).toEqual([ 33 | { type: 'YAY', foo: 'one', bar: 'two' } 34 | ]) 35 | }) 36 | 37 | it('uses specific saga options such as the channel', () => { 38 | const dispatched = []; 39 | const saga = runSaga({ 40 | dispatch: (action) => dispatched.push(action), 41 | getState: () => ({}), 42 | }, handleSagaErrors(function* () { 43 | throw new Error('Oups.') 44 | }, { 45 | channel: 'pictureUploader' 46 | })); 47 | 48 | expect(dispatched.length).toBe(1); 49 | expect(dispatched[0].error).toEqual(new Error('Oups.')); 50 | expect(dispatched[0].options).toEqual({ 51 | channel: 'pictureUploader' 52 | }); 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /native/src/collection/components/CollectionComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ReactNode } from 'react' 2 | import { StyleProp, Text, ViewStyle, FlatList } from 'react-native' 3 | 4 | import Collection from '../Collection' 5 | import { FormLabel } from 'react-native-elements' 6 | import ZeroStatePlaceholder from '../../empty-state/ZeroStatePlaceholder' 7 | 8 | export type Props = { 9 | collection: Collection 10 | renderRow: (item: any, id: string | number) => any 11 | title?: string 12 | listViewStyle?: StyleProp 13 | zeroStatePlaceHolderMessage?: string 14 | zeroStatePlaceHolder?: ReactNode 15 | testID?: string 16 | } 17 | 18 | export default class CollectionComponent extends Component { 19 | render() { 20 | let {collection, renderRow} = this.props; 21 | let dataSource = collection.dataSource(); 22 | 23 | return ( 24 | 25 | {this.props.title && ( 26 | {this.props.title} 27 | )} 28 | 29 | {collection.hasError() && ( 30 | Something went wrong, sorry for the inconvenience. 31 | )} 32 | 33 | {dataSource.length === 0 && !collection.isLoading() && ( 34 | this.props.zeroStatePlaceHolder ? this.props.zeroStatePlaceHolder : ( 35 | 36 | ) 37 | )} 38 | 39 | renderRow(item, index)} 44 | keyExtractor={(_, index ) => index.toString()} 45 | /> 46 | 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /core/tests/store/redux/reducers.test.ts: -------------------------------------------------------------------------------- 1 | import {reduceListAndItems} from "../../../src/store/redux/reducers"; 2 | 3 | describe('Reducers for list & items', () => { 4 | it('sets the list default', () => { 5 | const state = reduceListAndItems(undefined, { type: 'IT_STARTS' }, { 6 | actions: { 7 | starting: 'IT_STARTS', 8 | succeed: 'IT_WORKED', 9 | failed: 'IT_FAILED' 10 | }, 11 | items: [], 12 | listKeyInState: 'a-list', 13 | itemIdentifierResolver: (item : any) => item.id, 14 | }) 15 | 16 | expect(state).toEqual({ 17 | "a-list": { 18 | error: null, 19 | loading: true 20 | } 21 | }) 22 | }); 23 | 24 | it('does nothing for an unknown action', () => { 25 | const state = reduceListAndItems(undefined, { type: 'SOMETHING_COMPLETELY_DIFFERENT' }, { 26 | actions: { 27 | starting: 'IT_STARTS', 28 | succeed: 'IT_WORKED', 29 | failed: 'IT_FAILED' 30 | }, 31 | items: [], 32 | listKeyInState: 'a-list', 33 | itemIdentifierResolver: (item : any) => item.id, 34 | }) 35 | 36 | expect(state).toEqual({}); 37 | }); 38 | 39 | it('does not do remove the existing items for an unknown action', () => { 40 | const initialState = { 41 | list: { 42 | identifiers: ['abc'] 43 | }, 44 | abc: { 45 | firstname: 'Sam' 46 | } 47 | }; 48 | 49 | const state = reduceListAndItems(initialState, { type: 'SOMETHING_COMPLETELY_DIFFERENT' }, { 50 | actions: { 51 | starting: 'IT_STARTS', 52 | succeed: 'IT_WORKED', 53 | failed: 'IT_FAILED' 54 | }, 55 | items: [], 56 | listKeyInState: 'list', 57 | itemIdentifierResolver: (item : any) => item.id, 58 | }); 59 | 60 | expect(state).toEqual(initialState); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /native/src/loaded-entity/components/WaitUntilEntityIsLoadedFactory.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {ActivityIndicator, View} from "react-native"; 3 | 4 | type State = { 5 | isLoading: boolean; 6 | } 7 | 8 | export default function WaitUntilEntityIsLoadedFactory (DecoratedComponent, descriptor) { 9 | return class extends React.Component<{}, State> { 10 | static navigationOptions = DecoratedComponent.navigationOptions; 11 | 12 | constructor(props) { 13 | super(props); 14 | 15 | this.state = { 16 | isLoading: false 17 | }; 18 | } 19 | 20 | componentDidMount() { 21 | this.ensureObjectIsLoaded(); 22 | } 23 | 24 | componentDidUpdate(prevProps) { 25 | if (descriptor.havePropertiesChanged(prevProps, this.props)) { 26 | this.ensureObjectIsLoaded(); 27 | } 28 | } 29 | 30 | ensureObjectIsLoaded() { 31 | if (!descriptor.objectIsLoaded(this.props)) { 32 | if (!this.state.isLoading) { 33 | this.setState({ 34 | isLoading: true 35 | }); 36 | 37 | descriptor.loadObject(this.props); 38 | } 39 | } else if (this.state.isLoading) { 40 | this.setState({ 41 | isLoading: false 42 | }); 43 | } 44 | } 45 | 46 | render() { 47 | if (!descriptor.objectIsLoaded(this.props)) { 48 | return ( 49 | 58 | 61 | 62 | ) 63 | } 64 | 65 | return 66 | } 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /native/tests/collection/components/ScrollableCollection.test.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollView, Text } from 'react-native' 2 | 3 | import Collection from '../../../src/collection/Collection' 4 | import React from 'react' 5 | import ScrollableCollection from '../../../src/collection/components/ScrollableCollection' 6 | import renderer from 'react-test-renderer' 7 | 8 | const noop = () => {} 9 | const renderRow = (item: any) => {item.foo} 10 | 11 | test('it displays only one loading indicator on the first page', () => { 12 | const collection = new Collection({ 13 | items: [{ foo: 'bar' }], 14 | loading: true, 15 | up_to_page: 1, 16 | total_items: 10, 17 | }) 18 | 19 | const tree = renderer 20 | .create() 21 | .toJSON() 22 | 23 | expect(tree).toMatchSnapshot() 24 | }) 25 | 26 | test('it displays the bottom loading indicator when more than one page has been loaded', () => { 27 | const collection = new Collection({ 28 | items: [{ foo: 'bar' }, { foo: 'baz' }], 29 | loading: true, 30 | up_to_page: 2, 31 | total_items: 10, 32 | }) 33 | 34 | const tree = renderer 35 | .create() 36 | .toJSON() 37 | 38 | expect(tree).toMatchSnapshot() 39 | }) 40 | 41 | it('passes a testID prop down to the ScrollView component', () => { 42 | const collection = new Collection({ 43 | items: [{ foo: 'bar' }], 44 | loading: true, 45 | up_to_page: 1, 46 | total_items: 10, 47 | }) 48 | 49 | const tree = renderer.create( 50 | 51 | ) 52 | 53 | const scrollView = tree.root.findByType(ScrollView) 54 | expect(scrollView.props.testID).toBe('test-id') 55 | }) 56 | -------------------------------------------------------------------------------- /native/tests/loaded-entity/connectEntity.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import connectEntity from "../../src/loaded-entity/connectEntity"; 4 | import configureStore from 'redux-mock-store' //ES6 modules 5 | import { loadUser } from "./support/actions"; 6 | import { SimpleComponentExpectingAUserEntity } from "./support/components"; 7 | 8 | const middlewares = [] 9 | const mockStore = configureStore(middlewares) 10 | 11 | test('it displays a loading indicator and dispatches the action if entity was not yet loaded', () => { 12 | const initialState = { users: {} } 13 | const store = mockStore(initialState) 14 | const WrappedComponent = connectEntity(SimpleComponentExpectingAUserEntity, { 15 | property: 'user', 16 | loadEntityAction: loadUser, 17 | entitySelector: (state, username) => state.users[username], 18 | identifierFromPropsResolver: props => props.username 19 | }) 20 | 21 | const tree = renderer.create( 22 | 23 | ).toJSON(); 24 | 25 | expect(tree).toMatchSnapshot(); 26 | expect(store.getActions()).toEqual([ 27 | loadUser('sam'), 28 | ]); 29 | }); 30 | 31 | test('it renders the component with the right property', () => { 32 | const initialState = { 33 | users: { 34 | sam: { 35 | name: 'Samuel Roze' 36 | } 37 | } 38 | }; 39 | 40 | const store = mockStore(initialState) 41 | const WrappedComponent = connectEntity(SimpleComponentExpectingAUserEntity, { 42 | property: 'user', 43 | loadEntityAction: loadUser, 44 | entitySelector: (state, username) => state.users[username], 45 | identifierFromPropsResolver: props => props.username 46 | }) 47 | 48 | const tree = renderer.create( 49 | 50 | ).toJSON(); 51 | 52 | expect(tree).toMatchSnapshot(); 53 | expect(store.getActions()).toEqual([]); 54 | }) 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Galette 6 | 7 | Galette is a set of tools, components and screens to be re-used within your applications. Built on the shoulders of 8 | React & Redux, these modules will get you up and running very fast. 9 | 10 | ## Core 11 | 12 | The core contains modules that are generic. They can be used both for React and React Native. 13 | 14 | ### Installation 15 | 16 | ``` 17 | npm add @birdiecare/galette-core 18 | ``` 19 | 20 | ### Modules 21 | 22 | - [**Store**](./core/src/store)
23 | Reducers, selectors and helpers to store your collections and items in your Redux store. 24 | 25 | - [**Typed Action Creator**](./core/src/typed-action-creator)
26 | No more exporting action types and action creators. Type is within the creator 🙃 27 | 28 | - [**Hydra**](./core/src/hydra)
29 | Super-set of Store, adding specifics for Hydra APIs. 30 | 31 | - [**ram** (redux-api-middleware)](./core/src/redux-api-middleware)
32 | Superset of Store, adding specifics for redux-api-middleware. 33 | 34 | ## React Native 35 | 36 | ### Installation 37 | 38 | ``` 39 | npm add @birdiecare/galette-native 40 | ``` 41 | 42 | ### Modules 43 | 44 | - [**Loaded entity**](./native/src/loaded-entity)
45 | Ensure that an entity is loaded before loading the component. 46 | 47 | - [**Errors**](./native/src/errors)
48 | Error handling for humans! Error messages, dismisses, retries, etc... 49 | mostly automated. 50 | 51 | - [**InfiniteScrollView**](./native/src/infinite-scroll-view)
52 | An easy to use infinite scroll view for your paginated collections. 53 | 54 | - [**EmptyState**](./native/src/empty-state)
55 | Set of screens to be used when nothing has been found. 56 | 57 | ## React Web 58 | 59 | ### Installation 60 | 61 | ``` 62 | npm add @birdiecare/galette-web 63 | ``` 64 | 65 | ### Modules 66 | 67 | - [**TokenWall**](./web/src/token-wall)
68 | Adding a simple token wall for your early prototype. 69 | -------------------------------------------------------------------------------- /native/src/errors/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import { View, Text, TouchableHighlight, StyleSheet} from "react-native"; 3 | import { connect } from "react-redux"; 4 | import {store, ReportedError} from "@birdiecare/galette-core"; 5 | 6 | import RetryButton from "./RetryButton"; 7 | 8 | const {actions: {dismissError}} = store; 9 | 10 | type Props = { 11 | reportedError: ReportedError; 12 | retryEnabled?: boolean; 13 | containerStyle?: any; 14 | 15 | dismissError: (identifier: string) => void; 16 | retry: (error: ReportedError) => void; 17 | } 18 | 19 | class ErrorMessage extends Component 20 | { 21 | static defaultProps = { 22 | retryEnabled: true, 23 | }; 24 | 25 | render() { 26 | const { reportedError, retryEnabled } = this.props; 27 | 28 | return ( 29 | 30 | { 31 | this.props.dismissError(reportedError.identifier); 32 | }} style={{flex: 1}}> 33 | 34 | {reportedError.message} 35 | {retryEnabled && reportedError.triggerAction && ( 36 | this.props.retry(reportedError)} /> 37 | )} 38 | 39 | 40 | 41 | ) 42 | } 43 | } 44 | 45 | const styles = StyleSheet.create({ 46 | errorContainer: { 47 | minHeight: 38, 48 | backgroundColor: 'red', 49 | borderTopWidth: 1, 50 | borderTopColor: '#fff' 51 | }, 52 | touchableHighlightContainer: { 53 | flex: 1, 54 | flexDirection: 'row', 55 | alignItems: 'center', 56 | padding: 5 57 | }, 58 | errorText: { 59 | padding: 5, 60 | flex: 1, 61 | color: 'white' 62 | } 63 | }); 64 | 65 | export default connect(undefined, dispatch => { 66 | return { 67 | retry: (reportedError : ReportedError) => { 68 | dispatch(reportedError.triggerAction); 69 | dispatch(dismissError(reportedError.identifier)); 70 | }, 71 | dismissError: identifier => dispatch(dismissError(identifier)), 72 | } 73 | })(ErrorMessage); 74 | -------------------------------------------------------------------------------- /native/src/collection/components/ScrollableCollection.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ReactNode } from "react"; 2 | import { RefreshControl, StyleProp, ViewStyle } from "react-native"; 3 | import CollectionComponent, { 4 | Props as CollectionComponentProps 5 | } from "./CollectionComponent"; 6 | import InfiniteScrollView from "../../infinite-scroll-view/InfiniteScrollView"; 7 | import Collection from "../Collection"; 8 | 9 | type Props = CollectionComponentProps & { 10 | collection: Collection; 11 | onRefresh: (page?: number) => void; 12 | hideRefreshAnimation?: boolean; 13 | header?: ReactNode; 14 | numberOfItemsForFullPage?: StyleProp; 15 | style?: any; 16 | }; 17 | 18 | export default class ScrollableCollection extends Component { 19 | static defaultProps = { 20 | numberOfItemsForFullPage: 10, 21 | header: null 22 | }; 23 | 24 | componentDidMount() { 25 | this.props.onRefresh(); 26 | } 27 | 28 | render() { 29 | let { collection, hideRefreshAnimation } = this.props; 30 | 31 | return ( 32 | 33 | this.props.onRefresh(1)} 40 | title="Loading..." 41 | tintColor="#fff" 42 | titleColor="#fff" 43 | /> 44 | } 45 | isLoading={collection.isLoading()} 46 | canLoadMore={collection.hasMore()} 47 | displayLoading={this._shouldDisplayBottomLoading(collection)} 48 | distanceToLoadMore={100} 49 | onLoadMoreAsync={() => { 50 | this.props.onRefresh(collection.getPage() + 1); 51 | }} 52 | > 53 | {this.props.header} 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | _shouldDisplayBottomLoading(collection) { 61 | return ( 62 | collection.getPage() > 1 || 63 | collection.items().length > this.props.numberOfItemsForFullPage 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /native/src/errors/components/ErrorWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {View, StyleSheet} from "react-native"; 3 | import { connect } from "react-redux"; 4 | import { bindActionCreators } from "redux"; 5 | 6 | import ErrorMessage from "./ErrorMessage"; 7 | import { reportedErrors } from "../store"; 8 | import {store, ReportedError} from "@birdiecare/galette-core"; 9 | 10 | const {actions: {reportError}} = store; 11 | 12 | type Props = { 13 | children: any; 14 | errors: ReportedError[]; 15 | style?: any; 16 | channel?: string; 17 | floating?: boolean; 18 | retryEnabled?: boolean; 19 | 20 | reportError: (error: Error) => void; 21 | renderError?: (error: Error) => React.ComponentType; 22 | } 23 | 24 | class ErrorWrapper extends Component 25 | { 26 | static defaultProps = { 27 | floating: true, 28 | } 29 | 30 | componentDidCatch(error, info) { 31 | this.props.reportError(error); 32 | } 33 | 34 | render() { 35 | const errorsContainerStyles: any[] = [ 36 | styles.forwardContainer, 37 | ]; 38 | 39 | if (this.props.floating) { 40 | errorsContainerStyles.push(styles.floatingBottomContainer); 41 | } 42 | 43 | return ( 44 | 45 | {this.props.errors.length > 0 && ( 46 | 47 | {this.props.errors.map((error, index) => ( 48 | 49 | {this.renderError(error)} 50 | 51 | ))} 52 | 53 | )} 54 | {this.props.children} 55 | 56 | ); 57 | } 58 | 59 | renderError(error) { 60 | if (this.props.renderError) { 61 | return this.props.renderError(error); 62 | } 63 | 64 | return ( 65 | 68 | ) 69 | } 70 | } 71 | 72 | const styles = StyleSheet.create({ 73 | forwardContainer: { 74 | zIndex: 500 75 | }, 76 | floatingBottomContainer: { 77 | position: 'absolute', 78 | left: 0, 79 | right: 0, 80 | bottom: 0, 81 | } 82 | }) 83 | 84 | export default connect((state, props) => { 85 | return { 86 | errors: reportedErrors(state, props.channel), 87 | } 88 | }, dispatch => bindActionCreators({ 89 | reportError, 90 | }, dispatch))(ErrorWrapper); 91 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy-core-and-native: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Check the package.json of /core to see if it has changed 17 | uses: EndBug/version-check@v1.3.0 18 | with: 19 | file-name: core/package.json 20 | diff-search: true 21 | id: core-version-change 22 | 23 | - name: Check the package.json of /native to see if it has changed 24 | uses: EndBug/version-check@v1.3.0 25 | with: 26 | file-name: native/package.json 27 | diff-search: true 28 | id: native-version-change 29 | 30 | - name: Use Node.js 12.x 31 | uses: actions/setup-node@v1 32 | with: 33 | node-version: 12.x 34 | registry-url: https://npm.pkg.github.com/ 35 | scope: '@birdiecare' 36 | 37 | - name: Install common dependencies 38 | run: | 39 | echo "//npm.pkg.github.com/:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc 40 | env: 41 | NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }} 42 | NODE_ENV: development 43 | 44 | - name: Build core 45 | working-directory: core 46 | run: | 47 | echo "//npm.pkg.github.com/:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc 48 | npm ci 49 | npm run build 50 | env: 51 | NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }} 52 | NODE_ENV: development 53 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Deploy core 56 | if: steps.core-version-change.outputs.changed == 'true' 57 | working-directory: core 58 | run: | 59 | npm publish 60 | env: 61 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | 63 | - name: Build and deploy native 64 | if: steps.native-version-change.outputs.changed == 'true' 65 | working-directory: native 66 | run: | 67 | echo "//npm.pkg.github.com/:_authToken=$NPM_REGISTRY_TOKEN" >> .npmrc 68 | npm ci 69 | npm run build 70 | npm publish 71 | env: 72 | NPM_REGISTRY_TOKEN: ${{ secrets.NPM_REGISTRY_TOKEN }} 73 | NODE_ENV: development 74 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | -------------------------------------------------------------------------------- /native/src/loaded-entity/README.md: -------------------------------------------------------------------------------- 1 | # Loaded Entity 2 | 3 | The use-case is very simple, yet not commoditised. You want to display a detail 4 | view for an entity. You want to: 5 | 6 | * Display a feedback that the data is being loaded (if not yet available) 7 | * Display your component only once the entity is loaded completely 8 | * Handle errors in loading the entity 9 | 10 | Wrapping your component, this module will display a loading indicator until your entity is properly loaded, and give this 11 | entity as a prop of your component. 12 | 13 | ## Usage 14 | 15 | The module ships with a `connectEntity` helper which hides most of the complexity. Alternatively, you can use the wrapper 16 | component directly for a greater flexibility. 17 | 18 | ### With the `connectEntity` helper 19 | 20 | In this example, we load the user if it is not yet found in the store. 21 | 22 | ```javascript 23 | import {connectEntity} from "@kamet/native"; 24 | 25 | class UserScreen extends Component 26 | { 27 | render() { 28 | console.log('Render user from: ', this.props.user); 29 | } 30 | } 31 | 32 | export default connectEntity(UserScreen, { 33 | property: 'user', 34 | loadEntityAction: loadUser, 35 | entitySelector: (state, username) => state.users[username], 36 | identifierFromPropsResolver: props => props.navigation.state.params.user_id 37 | }); 38 | ``` 39 | 40 | ### Just with the wrapping component 41 | 42 | ```javascript 43 | import {bindActionCreators} from "redux"; 44 | import {connect} from "react-redux"; 45 | import {WaitUntilEntityIsLoadedFactory} from "@kamet/native"; 46 | 47 | const usernameFromProps = (props = {}) => { 48 | return props.username 49 | || (props.user && usernameFromProps(props.user)) 50 | || (props.navigation && usernameFromProps(props.navigation.state.params)); 51 | }; 52 | 53 | export const connectEntity = (DecoratedComponent) => { 54 | return connect((state, props) => { 55 | let username = usernameFromProps(props); 56 | let user = username in state.users ? state.users[username] : {}; 57 | 58 | return { 59 | user 60 | } 61 | }, dispatch => { 62 | return bindActionCreators({ 63 | , 64 | }, dispatch); 65 | })(WaitUntilEntityIsLoadedFactory(DecoratedComponent, { 66 | havePropertiesChanged: (prevProps, newProps) => { 67 | return prevProps.user !== newProps.user; 68 | }, 69 | loadObject: (props) => { 70 | props.loadUser(usernameFromProps(props)); 71 | }, 72 | objectIsLoaded: (props) => { 73 | return props.user && props.user.name; 74 | } 75 | })); 76 | }; 77 | ``` 78 | -------------------------------------------------------------------------------- /core/src/redux-api-middleware/README.md: -------------------------------------------------------------------------------- 1 | # Redux Api Middleware 2 | 3 | [`redux-api-middleware`](https://github.com/agraboso/redux-api-middleware) is a library 4 | aiming to simplify and normalise API calls via special Redux actions. Galette provides 5 | a set of functions that helps its integration with our [Store module](../store). 6 | 7 | ## Usage 8 | 9 | **Instead** of `import`ing the store functions directly, use the the functions from 10 | this module when you need to reduce `redux-api-middleware` actions, like in the 11 | following example: 12 | 13 | ```javascript 14 | import { store, ram } from "@birdiecare/galette-core"; 15 | const { functions: { updateItem }, reducers: { reduceList, reduceItems } } = store; 16 | const { functions: { payloadResolver, createApiCallReducer } } = ram; 17 | 18 | // Identifier resolver for users objects 19 | const userIdentifierResolver = user => user.username; 20 | 21 | // `users` reducer 22 | export const users = (state, action) => { 23 | // ... 24 | 25 | if (action.type.indexOf(LOAD_USER_FOLLOWERS) === 0) { 26 | let username = action.meta && action.meta.username; 27 | 28 | state = reduceItems(state, action, { 29 | itemIdentifierResolver: userIdentifierResolver, 30 | payloadResolver, 31 | }); 32 | 33 | return updateItem(state, username, reduceList(state[username], action, { 34 | listKeyInState: 'followers_list', 35 | actionPrefix: LOAD_USER_FOLLOWERS, 36 | itemIdentifierResolver: userIdentifierResolver, 37 | payloadResolver, 38 | })); 39 | } 40 | 41 | // ... 42 | } 43 | ``` 44 | 45 | This example will reduce the list of followers for a given user. Using the `payloadResolver` 46 | specialised for `redux-api-middleware`, it will reduce each "follower" (which are users) 47 | item individually to prevent any duplication or outdated information in our Redux store 48 | and will then reduce the list of the followers stored within the `followers_list` property 49 | of the user in the store. 50 | 51 | Let's illustrate this with a before/action/after example. 52 | 53 | **State Before:** 54 | ```yaml 55 | users: 56 | alistair: { username: alistair, email: old@email.com } 57 | david: { username: david, email: dav@id.com } 58 | samuel: { username: samuel, email: samuel.roze@gmail.com } 59 | ``` 60 | 61 | **Action** 62 | ```yaml 63 | type: LOAD_USER_FOLLOWERS_RECEIVED 64 | payload: [ { username: christelle, email: chri@elle.com }, { username: alistair, email: al@id.com } ] 65 | meta: 66 | username: samuel 67 | ``` 68 | 69 | **After** 70 | ```yaml 71 | users: 72 | alistair: { username: alistair, email: old@email.com } 73 | christelle: { username: christelle, email: chri@elle.com } 74 | david: { username: david, email: dav@id.com } 75 | samuel: 76 | username: samuel 77 | email: samuel.roze@gmail.com 78 | followers_list: 79 | identifiers: [ christelle, alistair ] 80 | # others list properties... 81 | ``` 82 | -------------------------------------------------------------------------------- /native/tests/collection/components/__snapshots__/ScrollableCollection.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`it displays only one loading indicator on the first page 1`] = ` 4 | 19 | } 20 | renderLoadingErrorIndicator={[Function]} 21 | renderLoadingIndicator={[Function]} 22 | scrollEventThrottle={100} 23 | style={ 24 | Object { 25 | "flex": 1, 26 | } 27 | } 28 | > 29 | 30 | 31 | 61 | 62 | 66 | 67 | bar 68 | 69 | 70 | 71 | 72 | 73 | 74 | `; 75 | 76 | exports[`it displays the bottom loading indicator when more than one page has been loaded 1`] = ` 77 | 92 | } 93 | renderLoadingErrorIndicator={[Function]} 94 | renderLoadingIndicator={[Function]} 95 | scrollEventThrottle={100} 96 | style={ 97 | Object { 98 | "flex": 1, 99 | } 100 | } 101 | > 102 | 103 | 104 | 137 | 138 | 142 | 143 | bar 144 | 145 | 146 | 150 | 151 | baz 152 | 153 | 154 | 155 | 156 | 167 | 173 | 174 | 175 | 176 | `; 177 | -------------------------------------------------------------------------------- /core/src/store/redux/reducers.ts: -------------------------------------------------------------------------------- 1 | import { updateItem } from "./functions"; 2 | import { AnyAction } from "redux"; 3 | 4 | export type ItemsResolver = (action: AnyAction) => any[]; 5 | export type ItemsOption = any[] | ItemsResolver; 6 | export type ReduceItemsOptions = { 7 | items: ItemsOption; 8 | itemIdentifierResolver: (item: object) => string; 9 | itemTransformer?: (item: object) => object; 10 | }; 11 | 12 | export type ActionLifecycle = { 13 | starting: string; 14 | failed: string; 15 | succeed: string; 16 | }; 17 | 18 | export type ActionLifecycleOptions = { 19 | actionPrefix?: string; 20 | actions: ActionLifecycle; 21 | }; 22 | 23 | export type ReduceListOptions = ReduceItemsOptions & 24 | ActionLifecycleOptions & { 25 | listKeyInState: string; 26 | 27 | errorMessageResolver?: (action: AnyAction) => string; 28 | totalItems?: (action: AnyAction) => number; 29 | }; 30 | 31 | export type ReducedList = { 32 | identifiers: string[]; 33 | 34 | up_to_page?: number; 35 | loading?: number; 36 | error?: string; 37 | total_items?: number; 38 | }; 39 | 40 | const resolveActionsToHandle = (options: ReduceListOptions) => { 41 | if (options.actionPrefix) { 42 | options.actions = { 43 | starting: options.actionPrefix + "_SENT", 44 | failed: options.actionPrefix + "_FAILED", 45 | succeed: options.actionPrefix + "_RECEIVED" 46 | }; 47 | } 48 | 49 | return options.actions; 50 | }; 51 | 52 | export const reduceListAndItems = ( 53 | state = {}, 54 | action: AnyAction, 55 | options: ReduceListOptions 56 | ) => { 57 | const actions = resolveActionsToHandle(options); 58 | const actionTypes = Object.keys(actions).map( 59 | (key: "starting" | "failed" | "succeed") => actions[key] 60 | ); 61 | if (actionTypes.indexOf(action.type) === -1) { 62 | return state; 63 | } 64 | 65 | return reduceList(reduceItems(state, action, options), action, options); 66 | }; 67 | 68 | function itemsFromAction(action: AnyAction, options: ReduceItemsOptions) { 69 | let items = 70 | "function" === typeof options.items ? options.items(action) : options.items; 71 | 72 | if (options.itemTransformer) { 73 | items = items.map(options.itemTransformer); 74 | } 75 | 76 | return items; 77 | } 78 | 79 | const defaultErrorMessageResolver = (action: AnyAction) => { 80 | const messageSource = action.error || action.payload || {}; 81 | 82 | return ( 83 | messageSource.message || messageSource.error || "Something went wrong." 84 | ); 85 | }; 86 | 87 | export const reduceItems = ( 88 | state = {}, 89 | action: AnyAction, 90 | options: ReduceItemsOptions 91 | ) => { 92 | let items = itemsFromAction(action, options); 93 | 94 | for (let i = 0; i < items.length; i++) { 95 | const item = items[i]; 96 | 97 | state = updateItem(state, options.itemIdentifierResolver(item), item); 98 | } 99 | 100 | return state; 101 | }; 102 | 103 | export const reduceList = ( 104 | state: any = {}, 105 | action: AnyAction, 106 | options: ReduceListOptions 107 | ): ReducedList => { 108 | const actions = resolveActionsToHandle(options); 109 | 110 | // If the list does not exists. 111 | if (!state[options.listKeyInState]) { 112 | state = updateItem(state, options.listKeyInState, {}); 113 | } 114 | 115 | if (action.type === actions.starting) { 116 | return updateItem(state, options.listKeyInState, { 117 | loading: true, 118 | error: null 119 | }); 120 | } 121 | 122 | if (action.type === actions.succeed) { 123 | const items = itemsFromAction(action, options); 124 | const totalItems = 125 | "function" === typeof options.totalItems 126 | ? options.totalItems(action) 127 | : undefined; 128 | 129 | const loadedIdentifiers = items.map(options.itemIdentifierResolver); 130 | 131 | let identifiers = loadedIdentifiers; 132 | 133 | // If there is a page... 134 | if (action.meta && action.meta.page && action.meta.page > 1) { 135 | const currentPage = state[options.listKeyInState].up_to_page; 136 | const currentIdentifiers = state[options.listKeyInState].identifiers; 137 | 138 | // Same page, we ignore. 139 | if (currentPage == action.meta.page) { 140 | identifiers = currentIdentifiers; 141 | } else if (currentPage < action.meta.page) { 142 | // And the page is above the current page 143 | identifiers = currentIdentifiers.concat(loadedIdentifiers); 144 | } 145 | } 146 | 147 | return updateItem(state, options.listKeyInState, { 148 | identifiers, 149 | up_to_page: action.meta && action.meta.page, 150 | loading: false, 151 | total_items: totalItems 152 | }); 153 | } 154 | 155 | if (action.type === actions.failed) { 156 | const errorResolver = options.errorMessageResolver 157 | ? options.errorMessageResolver 158 | : defaultErrorMessageResolver; 159 | return updateItem(state, options.listKeyInState, { 160 | loading: false, 161 | error: errorResolver(action) 162 | }); 163 | } 164 | 165 | return state; 166 | }; 167 | -------------------------------------------------------------------------------- /native/src/infinite-scroll-view/InfiniteScrollView.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import {ScrollView, ScrollViewProperties, View} from 'react-native'; 3 | import ScrollableMixin from 'react-native-scrollable-mixin'; 4 | import cloneReferencedElement from 'react-clone-referenced-element'; 5 | import DefaultLoadingIndicator from './DefaultLoadingIndicator'; 6 | 7 | type Props = ScrollViewProperties & { 8 | onLoadMoreAsync: () => void | Promise; 9 | 10 | displayLoading?: boolean; 11 | renderLoadingErrorIndicator?: (options: any) => any; 12 | renderLoadingIndicator?: () => any; 13 | renderScrollComponent?: (props : any) => any; 14 | onScroll?: (event : any) => void; 15 | canLoadMore?: boolean | (() => boolean); 16 | distanceToLoadMore?: number; 17 | onLoadError?: (e : Error) => void; 18 | isLoading?: boolean; 19 | }; 20 | 21 | type State = { 22 | isDisplayingError: boolean; 23 | isLoading?: boolean; 24 | }; 25 | 26 | export default class InfiniteScrollView extends Component { 27 | static defaultProps = { 28 | distanceToLoadMore: 1500, 29 | canLoadMore: false, 30 | isLoading: false, 31 | scrollEventThrottle: 100, 32 | displayLoading: true, 33 | renderLoadingIndicator: () => , 34 | renderLoadingErrorIndicator: () => , 35 | renderScrollComponent: props => , 36 | }; 37 | 38 | private _scrollComponent; 39 | 40 | constructor(props, context) { 41 | super(props, context); 42 | 43 | this.state = { 44 | isDisplayingError: false, 45 | }; 46 | 47 | this._handleScroll = this._handleScroll.bind(this); 48 | this._loadMoreAsync = this._loadMoreAsync.bind(this); 49 | } 50 | 51 | getScrollResponder() { 52 | return this._scrollComponent.getScrollResponder(); 53 | } 54 | 55 | setNativeProps(nativeProps) { 56 | this._scrollComponent.setNativeProps(nativeProps); 57 | } 58 | 59 | render() { 60 | let statusIndicator; 61 | 62 | if (this.state.isDisplayingError) { 63 | statusIndicator = React.cloneElement( 64 | this.props.renderLoadingErrorIndicator( 65 | {onRetryLoadMore: this._loadMoreAsync} 66 | ), 67 | {key: 'loading-error-indicator'}, 68 | ); 69 | } else if (this._isLoading() && this.props.displayLoading) { 70 | statusIndicator = React.cloneElement( 71 | this.props.renderLoadingIndicator(), 72 | {key: 'loading-indicator'}, 73 | ); 74 | } 75 | 76 | let { renderScrollComponent, ...props } = this.props; 77 | 78 | Object.assign(props, { 79 | onScroll: this._handleScroll, 80 | children: [this.props.children, statusIndicator], 81 | }); 82 | 83 | return cloneReferencedElement(renderScrollComponent(props), { 84 | ref: component => { 85 | this._scrollComponent = component; 86 | }, 87 | }); 88 | } 89 | 90 | _handleScroll(event) { 91 | if (this.props.onScroll) { 92 | this.props.onScroll(event); 93 | } 94 | 95 | if (this._shouldLoadMore(event)) { 96 | this._loadMoreAsync().catch(error => { 97 | console.error('Unexpected error while loading more content:', error); 98 | }); 99 | } 100 | } 101 | 102 | _shouldLoadMore(event) { 103 | let canLoadMore = (typeof this.props.canLoadMore === 'function') ? 104 | this.props.canLoadMore() : 105 | this.props.canLoadMore; 106 | 107 | return !this._isLoading() && 108 | canLoadMore && 109 | !this.state.isDisplayingError && 110 | this._distanceFromEnd(event) < this.props.distanceToLoadMore; 111 | } 112 | 113 | async _loadMoreAsync() { 114 | if (this._isLoading() && __DEV__) { 115 | throw new Error('_loadMoreAsync called while isLoading is true'); 116 | } 117 | 118 | try { 119 | this.setState({isDisplayingError: false, isLoading: true}); 120 | await this.props.onLoadMoreAsync(); 121 | } catch (e) { 122 | if (this.props.onLoadError) { 123 | this.props.onLoadError(e); 124 | } 125 | this.setState({isDisplayingError: true}); 126 | } finally { 127 | this.setState({isLoading: false}); 128 | } 129 | } 130 | 131 | _distanceFromEnd(event) { 132 | let { 133 | contentSize, 134 | contentInset, 135 | contentOffset, 136 | layoutMeasurement, 137 | } = event.nativeEvent; 138 | 139 | let contentLength; 140 | let trailingInset; 141 | let scrollOffset; 142 | let viewportLength; 143 | if (this.props.horizontal) { 144 | contentLength = contentSize.width; 145 | trailingInset = contentInset.right; 146 | scrollOffset = contentOffset.x; 147 | viewportLength = layoutMeasurement.width; 148 | } else { 149 | contentLength = contentSize.height; 150 | trailingInset = contentInset.bottom; 151 | scrollOffset = contentOffset.y; 152 | viewportLength = layoutMeasurement.height; 153 | } 154 | 155 | return contentLength + trailingInset - scrollOffset - viewportLength; 156 | } 157 | 158 | _isLoading() { 159 | return this.props.isLoading || this.state.isLoading; 160 | } 161 | } 162 | 163 | Object.assign(InfiniteScrollView.prototype, ScrollableMixin); 164 | -------------------------------------------------------------------------------- /native/src/errors/README.md: -------------------------------------------------------------------------------- 1 | # Errors 2 | 3 | Errors will happen. The network might not be reliable, a 3rd party might not 4 | be available, ... This module provides: 5 | 6 | - An automated catch of errors 7 | - A component wrapper to display the errors to the user 8 | - A retry mechanism 9 | 10 | ## Usage 11 | 12 | Once you've [set it up](#setup), you can use the wrapper component to display 13 | a nice error message to your users: 14 | 15 | ```javascript 16 | import { errors } from '@birdiecare/galette-native' 17 | const { components: { ErrorWrapper }} = errors; 18 | 19 | // app.js 20 | 21 | const App => () => ( 22 | 23 | 24 | {/** your router/components **/} 25 | 26 | 27 | ) 28 | ``` 29 | 30 | ### Manually Report errors 31 | 32 | To programatically report errors, you can simply dispatch an action created by 33 | the `reportError` action creator. 34 | 35 | ```javascript 36 | // YourComponent.js 37 | import { errors } from '@birdiecare/galette-native' 38 | const { actions: { reportError }} = errors; 39 | 40 | class YourComponent extends React.Component 41 | { 42 | componentWillMount() { 43 | this.props.reportError(new Error('Something went wrong.')); 44 | } 45 | } 46 | 47 | export default connect(undefined, dispatch => bindActionCreators({ 48 | reportError, 49 | }, dispatch))(YourComponent); 50 | ``` 51 | 52 | ## Setup 53 | 54 | ### reducer 55 | 56 | ```javascript 57 | import { errors } from '@birdiecare/galette-native' 58 | 59 | // Add the `errors.reducer` reducer to your store... 60 | const reducer = (state, action) => { 61 | return errors.reducer( 62 | yourMainReducer, 63 | action 64 | ); 65 | } 66 | ``` 67 | 68 | ### redux-saga 69 | 70 | Integrating `redux-saga` can be done in two (complementary) ways: 71 | 72 | 1. [Configure the `onLoad` option](#1-onload-method) of your Saga middleware to catch all saga errors 73 | at once. 74 | 2. [Decorate your sagas](#2-saga-decorator-method) for more flexibility and features. 75 | 76 | #### 1. `onLoad` method 77 | 78 | The `createSagaMiddleware` can take an `onError` callback. Galette ships with one 79 | that will report the error for you. 80 | 81 | ```javascript 82 | import createSagaMiddleware from 'redux-saga' 83 | import { errors } from '@birdiecare/galette-native' 84 | const { sagaErrorHandler } = errors; 85 | 86 | // Create your saga middleware with the `onError` handler 87 | const sagaMiddleware = createSagaMiddleware({ 88 | onError: sagaErrorHandler, 89 | }) 90 | 91 | // Create your `store` 92 | const store = /* ... */; 93 | 94 | // Set the `store` on the saga handler 95 | sagaErrorHandler.setStore(store); 96 | ``` 97 | 98 | This method applies to all the sagas at a time, which gives an very easy first step. 99 | Though, it does not allow to collect the action that failed and therefore you will 100 | not benefit from things like retries. To do so, you will need to decorate your sagas 101 | as described in the next section. 102 | 103 | #### 2. Saga decorator method 104 | 105 | The basic usage is simply to wrap your generators with the `handleSagaErrors` method: 106 | 107 | ```javascript 108 | // sagas.js 109 | import { errors } from '@birdiecare/galette-native' 110 | const { handleSagaErrors } = errors; 111 | 112 | const mySaga = handleSagaErrors(function*() { 113 | // Your own saga code... 114 | // yield ...; 115 | // ... 116 | }); 117 | ``` 118 | 119 | ## Options 120 | 121 | You can add options when reporting an error. 122 | This can be done either manually: 123 | 124 | ```javascript 125 | store.dispatch(reportError(new Error('ENOENT: File not found: /bad/file/path'), { 126 | message: 'There was an error while uploading the picture', 127 | channel: 'pictureUploader' 128 | })) 129 | ``` 130 | 131 | Or using a `redux-saga` decorator 132 | 133 | ```javascript 134 | const mySaga = handleSagaErrors(function*() { 135 | // Your own saga code... 136 | }, { 137 | message: 'There was an error while uploading the picture', 138 | channel: 'pictureUploader' 139 | }); 140 | ``` 141 | 142 | ### Message 143 | 144 | You can override the default error message by adding specifying the `message` in the options. 145 | 146 | ### Channels 147 | 148 | Errors might belong to different groups: _global_ errors when something went really 149 | wrong, errors on a login form, while uploading picture, etc... In order to report 150 | and/or display them differently, you can use channels. 151 | 152 | 153 | ## Display channel-ed messages 154 | 155 | The `ErrorWrapper` presented in the [Usage](#usage) section supports some properties 156 | as configuration. Use the `channel` property to drill down the messages to a channel: 157 | 158 | ```javascript 159 | class YourComponent extends React.Component 160 | { 161 | render() { 162 | return ( 163 | 164 | /* ... yours ... */ 165 | 166 | ) 167 | } 168 | } 169 | ``` 170 | 171 | ## Reference 172 | 173 | ### `ErrorWrapper` component 174 | 175 | The `ErrorWrapper` component accepts the following **optionnal** properties: 176 | 177 | - `channel` _string_
178 | The name of the channel to get the errors from 179 | 180 | - `floating` _boolean_
181 | By default, the errors are displayed as "floating" on top of other components. 182 | Providing `false` will ensure your component will be displayed _normally_. 183 | 184 | - `style` _object_
185 | Some styling for the error wrapper container. Typically, you might want to have 186 | `style={{flex: 1}}` to ensure the wrapper takes the entirety of your screen. 187 | 188 | - `renderError` _function `(error) => React.Component`_
189 | Renders an individual error. By default, it uses our built-in [`ErrorMessage` component](./src/errors/components/ErrorMessage.tsx). You can use your own component 190 | to have a custom user interface. 191 | 192 | - `retryEnabled` _boolean_
(default to `true`)
193 | Enables the retry button when possible. 194 | -------------------------------------------------------------------------------- /core/tests/store/redux/reducers.list.test.ts: -------------------------------------------------------------------------------- 1 | import { reduceList } from "../../../src/store/redux/reducers"; 2 | 3 | describe("Reduce list of items", () => { 4 | it("sets the loading status when starting", () => { 5 | const reduced = reduceList( 6 | undefined, 7 | { 8 | type: "MY_ACTION_SENT" 9 | }, 10 | { 11 | items: [], 12 | itemIdentifierResolver: (item: any) => item.id, 13 | actionPrefix: "MY_ACTION", 14 | listKeyInState: "list" 15 | } 16 | ); 17 | 18 | expect(reduced).toEqual({ 19 | list: { 20 | loading: true, 21 | error: null 22 | } 23 | }); 24 | }); 25 | 26 | it("sets the error when this happens", () => { 27 | const reduced = reduceList( 28 | { 29 | list: { 30 | loading: true, 31 | error: null 32 | } 33 | }, 34 | { 35 | type: "MY_ACTION_FAILED", 36 | payload: { 37 | message: "Invalid something..." 38 | } 39 | }, 40 | { 41 | items: [], 42 | itemIdentifierResolver: (item: any) => item.id, 43 | actionPrefix: "MY_ACTION", 44 | listKeyInState: "list" 45 | } 46 | ); 47 | 48 | expect(reduced).toEqual({ 49 | list: { 50 | loading: false, 51 | error: "Invalid something..." 52 | } 53 | }); 54 | }); 55 | 56 | it("set the list item identifiers", () => { 57 | const reduced = reduceList( 58 | { 59 | list: { 60 | loading: true 61 | } 62 | }, 63 | { 64 | type: "MY_ACTION_RECEIVED" 65 | }, 66 | { 67 | items: [ 68 | { id: 1, name: "Foo" }, 69 | { id: 2, name: "Bar" } 70 | ], 71 | itemIdentifierResolver: (item: any) => item.id, 72 | actionPrefix: "MY_ACTION", 73 | listKeyInState: "list" 74 | } 75 | ); 76 | 77 | expect(reduced).toEqual({ 78 | list: { 79 | loading: false, 80 | identifiers: [1, 2], 81 | total_items: undefined, 82 | up_to_page: undefined 83 | } 84 | }); 85 | }); 86 | 87 | it("loads further pages", () => { 88 | const options = { 89 | itemIdentifierResolver: (item: any) => item.id, 90 | actionPrefix: "MY_ACTION", 91 | listKeyInState: "list", 92 | items: action => action.payload 93 | }; 94 | 95 | let state = {}; 96 | let state = reduceList( 97 | state, 98 | { 99 | type: "MY_ACTION_SENT" 100 | }, 101 | options 102 | ); 103 | 104 | let state = reduceList( 105 | state, 106 | { 107 | type: "MY_ACTION_RECEIVED", 108 | payload: [{ id: "1234" }], 109 | meta: { 110 | page: 1 111 | } 112 | }, 113 | options 114 | ); 115 | 116 | let state = reduceList( 117 | state, 118 | { 119 | type: "MY_ACTION_RECEIVED", 120 | payload: [{ id: "5678" }], 121 | meta: { 122 | page: 2 123 | } 124 | }, 125 | options 126 | ); 127 | 128 | expect(state).toMatchObject({ 129 | list: expect.objectContaining({ 130 | identifiers: ["1234", "5678"] 131 | }) 132 | }); 133 | }); 134 | 135 | it("loads further pages in an idempotent way", () => { 136 | const options = { 137 | itemIdentifierResolver: (item: any) => item.id, 138 | actionPrefix: "MY_ACTION", 139 | listKeyInState: "list", 140 | items: action => action.payload 141 | }; 142 | 143 | let state = {}; 144 | let state = reduceList( 145 | state, 146 | { 147 | type: "MY_ACTION_SENT" 148 | }, 149 | options 150 | ); 151 | 152 | let state = reduceList( 153 | state, 154 | { 155 | type: "MY_ACTION_RECEIVED", 156 | payload: [{ id: "1234" }], 157 | meta: { 158 | page: 1 159 | } 160 | }, 161 | options 162 | ); 163 | 164 | const secondPageReceived = { 165 | type: "MY_ACTION_RECEIVED", 166 | payload: [{ id: "5678" }], 167 | meta: { 168 | page: 2 169 | } 170 | }; 171 | 172 | let state = reduceList(state, secondPageReceived, options); 173 | let state = reduceList(state, secondPageReceived, options); 174 | 175 | expect(state).toMatchObject({ 176 | list: expect.objectContaining({ 177 | identifiers: ["1234", "5678"] 178 | }) 179 | }); 180 | }); 181 | 182 | it("overrides the 1st page", () => { 183 | const options = { 184 | itemIdentifierResolver: (item: any) => item.id, 185 | actionPrefix: "MY_ACTION", 186 | listKeyInState: "list", 187 | items: action => action.payload 188 | }; 189 | 190 | let state = {}; 191 | let state = reduceList( 192 | state, 193 | { 194 | type: "MY_ACTION_SENT" 195 | }, 196 | options 197 | ); 198 | 199 | let state = reduceList( 200 | state, 201 | { 202 | type: "MY_ACTION_RECEIVED", 203 | payload: [{ id: "1234" }], 204 | meta: { 205 | page: 1 206 | } 207 | }, 208 | options 209 | ); 210 | 211 | let state = reduceList( 212 | state, 213 | { 214 | type: "MY_ACTION_RECEIVED", 215 | payload: [{ id: "5678" }], 216 | meta: { 217 | page: 1 218 | } 219 | }, 220 | options 221 | ); 222 | 223 | expect(state).toMatchObject({ 224 | list: expect.objectContaining({ 225 | identifiers: ["5678"] 226 | }) 227 | }); 228 | }); 229 | 230 | it("supports to specify each action", () => { 231 | let state = {}; 232 | const reducerOptions = { 233 | items: [], 234 | itemIdentifierResolver: (item: any) => item.id, 235 | actions: { 236 | starting: "MY_ACTION_STARTING", 237 | failed: "IT_DID_FAIL", 238 | succeed: "YAY" 239 | }, 240 | listKeyInState: "list" 241 | }; 242 | 243 | state = reduceList(state, { type: "MY_ACTION_FAILED" }, reducerOptions); 244 | state = reduceList( 245 | state, 246 | { type: "IT_DID_FAIL", error: { oups: "Meh" } }, 247 | reducerOptions 248 | ); 249 | 250 | expect(state).toEqual({ 251 | list: { 252 | loading: false, 253 | error: "Something went wrong." 254 | } 255 | }); 256 | }); 257 | 258 | it("supports a custom error message resolver", () => { 259 | const reduced = reduceList( 260 | { 261 | list: { 262 | loading: true, 263 | error: null 264 | } 265 | }, 266 | { 267 | type: "MY_ACTION_FAILED", 268 | payload: { 269 | error: { 270 | myComplexErrorSchema: "With a message" 271 | } 272 | } 273 | }, 274 | { 275 | items: [], 276 | itemIdentifierResolver: (item: any) => item.id, 277 | actionPrefix: "MY_ACTION", 278 | listKeyInState: "list", 279 | errorMessageResolver: (action: any) => 280 | action.payload.error.myComplexErrorSchema 281 | } 282 | ); 283 | 284 | expect(reduced).toEqual({ 285 | list: { 286 | loading: false, 287 | error: "With a message" 288 | } 289 | }); 290 | }); 291 | }); 292 | -------------------------------------------------------------------------------- /core/src/store/README.md: -------------------------------------------------------------------------------- 1 | # Store 2 | 3 | Using an extensible and well-organised store structure is one of the most important things when using Redux. If you are 4 | not sure or want to know more about this, you should read [this article](https://hackernoon.com/shape-your-redux-store-like-your-database-98faa4754fd5). 5 | 6 | We have built a set of functions that allow you to store items and lists in an organised manner with almost no effort. 7 | Have a look to [how it will store your data](#how-is-it-stored) at the bottom of the file. 8 | 9 | ## Usage 10 | 11 | In order to make the documentation easy to reason about, we will describe here the different use cases you can encouter 12 | while dealing with lists and items: 13 | 14 | 1. [Simple list of items](#simple-list-of-items) 15 | 1. [List of items belonging to another one](#list-of-items-belonging-to-another-one) 16 | 1. [Custom action names](#custom-action-names) 17 | 18 | ### Simple list of items 19 | 20 | Let's use a list of users as our example. 21 | 22 | 1. You need to create your reducer. Use the `reduceListAndItems` helper to reduce both the list and each idividual item. 23 | 24 | ```javascript 25 | // reducer.js 26 | 27 | import { store } from "@birdiecare/galette-core"; 28 | const { reducers: { reduceListAndItems }} = store; 29 | 30 | export default const reducer = (state, action) => { 31 | return reduceListAndItems(state, action, { 32 | // The prefix for each of the actions to handle 33 | actionPrefix: 'LOAD_USERS', 34 | 35 | // In your `state`, the key to be used for the list. 36 | listKeyInState: 'list', 37 | 38 | // A function responsible of extracting the identifier for each item 39 | itemIdentifierResolver: item => item.id, 40 | 41 | // How to get the payload from a "success" action 42 | payloadResolver: action => action.payload 43 | }); 44 | } 45 | ``` 46 | 47 | 2. To get the list with your items, use the provided selector. Here is an example of a connected component: 48 | 49 | ```javascript 50 | // component.js 51 | 52 | import { store } from "@birdiecare/galette-core"; 53 | const { selectors: { collectionWithItems }} = store; 54 | 55 | class UserList extends Component 56 | { 57 | render() { 58 | console.log('render these users: ', this.props.users); 59 | } 60 | } 61 | 62 | export default connect(state => ({ 63 | users: collectionWithItems(state, 'list'), 64 | }))(UserList); 65 | ``` 66 | 67 | ### List of items belonging to another one 68 | 69 | Let's imagine that on top of our users (explained in the previous example), each 70 | of them gave a list of travels. We want to reduce the travel items in their own 71 | `travels` reducer so we don't have duplicates but we want to store the list on 72 | the user item. 73 | 74 | ```javascript 75 | // reducers.js 76 | 77 | import { store } from "@birdiecare/galette-core"; 78 | const { reducers: { reduceList, reduceItems }, functions: { updateItem }} = store; 79 | 80 | const travelIdentifierResolver = item => item.uuid; 81 | const payloadResolver = action => action.payload; 82 | 83 | export const users = (state, action) => { 84 | if (action.type.indexOf(LOAD_USER_TRAVELS) === 0) { 85 | const { username } = action; 86 | 87 | return updateItem(state, username, reduceList(state[username], action, { 88 | listKeyInState: 'travels', 89 | actionPrefix: LOAD_USER_TRAVELS, 90 | itemIdentifierResolver: travelIdentifierResolver, 91 | payloadResolver, 92 | })); 93 | } 94 | } 95 | 96 | export const travels = (state, action) => { 97 | if (action.type.indexOf(LOAD_USER_TRAVELS) === 0) { 98 | return reduceItems(state, action, { 99 | itemIdentifierResolver: travelIdentifierResolver, 100 | payloadResolver 101 | }) 102 | } 103 | } 104 | ``` 105 | 106 | You can now use the selector to get the travels of a given user in your component: 107 | ```javascript 108 | // component.js 109 | 110 | import { store } from "@birdiecare/galette-core"; 111 | const { selectors: { collectionWithItems }} = store; 112 | 113 | class UserTravelList extends Component 114 | { 115 | render() { 116 | console.log('travels of user "'+this.props.user.username+'": ', this.props.travels); 117 | } 118 | } 119 | 120 | export default connect((state, props) => ({ 121 | travels: collectionWithItems(props.user, 'travels', { 122 | itemResolver: identifier => { 123 | return state.travels[identifier]; 124 | } 125 | }), 126 | }))(UserTravelList); 127 | ``` 128 | 129 | ### Custom action names 130 | 131 | If you don't use actions ending with `_SENT`, `_SUCCESS` or `_FAILED`, you need to specify which actions should be 132 | considered by the reducer. 133 | 134 | Here is an example with the `reduceListAndItems` method (though it works for all of them): 135 | ```javascript 136 | reduceListAndItems(state, action, { 137 | // other options... 138 | actions: { 139 | starting: 'LOADING_USERS', 140 | failed: 'FAILED_USERS', 141 | succeed: 'LOADED_USERS' 142 | } 143 | }); 144 | ``` 145 | 146 | ## Functions 147 | 148 | 1. [`updateItem(state, identifier, propertiesToUpdate)`](#updateitem)
149 | Patch a set of properties of an item in a state. 150 | 151 | 1. [`createMappedReducer(initialState, reducerMapping)`](#createMappedReducer)
152 | Map reducers to a given set of action. 153 | 154 | ### `updateItem` 155 | 156 | Partially update an item in a list or object. 157 | 158 | ``` 159 | import { store } from "@birdiecare/galette-core" 160 | const { functions: { updateItem } } = store; 161 | 162 | expect( 163 | updateItem( 164 | {"1234": {"name": "foo", "type": "bar"}}, 165 | "1234", 166 | {"type": "baz"} 167 | ) 168 | ).toEqual( 169 | {"1234": {"name": "foo", "type", "baz"}} 170 | } 171 | ``` 172 | 173 | ### `createMappedReducer` 174 | 175 | Especially useful when using types, you can use the `createMappedReducer` function to create a reducer only interested 176 | in a small set of actions. Each action will have a specific "mini-reducer", in which you can type the action: 177 | 178 | ```javascript 179 | export const reducer = createMappedReducer(defaultState, { 180 | [reportError.type]: (state: ErrorModuleState, action: ReportErrorAction) => { 181 | return { 182 | ...state, 183 | // ... 184 | } 185 | }, 186 | 187 | [dismissError.type]: (state: ErrorModuleState, action: DismissErrorAction) => { 188 | return { 189 | ...state, 190 | // ... 191 | } 192 | } 193 | }); 194 | ``` 195 | 196 | ## How is it stored? 197 | 198 | Everything is resource-centric. It means it will not store duplicated resources. These resources will be indexed by 199 | identifier and the lists will only refer to identifiers. 200 | 201 | ### Example: Simple list of users 202 | 203 | The store will look like this: 204 | 205 | - `users` 206 | - `list` 207 | - `identifiers`
208 | An array of identifiers, like `["1234", "5678"]`. 209 | - `loading` A boolean 210 | - `error`. A error, if some. 211 | - `up_to_page`. The page at which the list is. 212 | - `1234` 213 | - `username` 214 | - `email` 215 | - `5678` 216 | - `username` 217 | - `email` 218 | 219 | ### Example: Users with a list of travels 220 | 221 | - `users` 222 | - `1234` (_user identifier_) 223 | - `travels` 224 | - `identifiers`. List of the travel identifiers. Example: `["1234"]` 225 | - `loading`. Boolean 226 | - `error`. An error if any. 227 | 228 | - `travels` 229 | - `1234` (_travel identifier_) 230 | - `name` 231 | - _other travel properties..._ 232 | -------------------------------------------------------------------------------- /web/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@birdiecare/galette-web", 3 | "version": "0.0.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "ansi-escapes": { 8 | "version": "1.4.0", 9 | "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", 10 | "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=" 11 | }, 12 | "ansi-regex": { 13 | "version": "2.1.1", 14 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", 15 | "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" 16 | }, 17 | "ansi-styles": { 18 | "version": "2.2.1", 19 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", 20 | "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" 21 | }, 22 | "babel-polyfill": { 23 | "version": "6.23.0", 24 | "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.23.0.tgz", 25 | "integrity": "sha1-g2TKYt+Or7gwSZ9pkXdGbDsDSZ0=", 26 | "requires": { 27 | "babel-runtime": "^6.22.0", 28 | "core-js": "^2.4.0", 29 | "regenerator-runtime": "^0.10.0" 30 | } 31 | }, 32 | "babel-runtime": { 33 | "version": "6.26.0", 34 | "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", 35 | "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", 36 | "requires": { 37 | "core-js": "^2.4.0", 38 | "regenerator-runtime": "^0.11.0" 39 | }, 40 | "dependencies": { 41 | "regenerator-runtime": { 42 | "version": "0.11.1", 43 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", 44 | "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" 45 | } 46 | } 47 | }, 48 | "chalk": { 49 | "version": "1.1.3", 50 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", 51 | "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", 52 | "requires": { 53 | "ansi-styles": "^2.2.1", 54 | "escape-string-regexp": "^1.0.2", 55 | "has-ansi": "^2.0.0", 56 | "strip-ansi": "^3.0.0", 57 | "supports-color": "^2.0.0" 58 | } 59 | }, 60 | "chardet": { 61 | "version": "0.4.2", 62 | "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", 63 | "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=" 64 | }, 65 | "cli-cursor": { 66 | "version": "2.1.0", 67 | "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", 68 | "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", 69 | "requires": { 70 | "restore-cursor": "^2.0.0" 71 | } 72 | }, 73 | "cli-width": { 74 | "version": "2.2.1", 75 | "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", 76 | "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==" 77 | }, 78 | "core-js": { 79 | "version": "2.6.11", 80 | "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", 81 | "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" 82 | }, 83 | "encoding": { 84 | "version": "0.1.13", 85 | "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", 86 | "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", 87 | "requires": { 88 | "iconv-lite": "^0.6.2" 89 | }, 90 | "dependencies": { 91 | "iconv-lite": { 92 | "version": "0.6.2", 93 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", 94 | "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", 95 | "requires": { 96 | "safer-buffer": ">= 2.1.2 < 3.0.0" 97 | } 98 | } 99 | } 100 | }, 101 | "escape-string-regexp": { 102 | "version": "1.0.5", 103 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 104 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" 105 | }, 106 | "external-editor": { 107 | "version": "2.2.0", 108 | "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", 109 | "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", 110 | "requires": { 111 | "chardet": "^0.4.0", 112 | "iconv-lite": "^0.4.17", 113 | "tmp": "^0.0.33" 114 | } 115 | }, 116 | "figures": { 117 | "version": "2.0.0", 118 | "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", 119 | "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", 120 | "requires": { 121 | "escape-string-regexp": "^1.0.5" 122 | } 123 | }, 124 | "has-ansi": { 125 | "version": "2.0.0", 126 | "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", 127 | "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", 128 | "requires": { 129 | "ansi-regex": "^2.0.0" 130 | } 131 | }, 132 | "iconv-lite": { 133 | "version": "0.4.24", 134 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 135 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 136 | "requires": { 137 | "safer-buffer": ">= 2.1.2 < 3" 138 | } 139 | }, 140 | "inquirer": { 141 | "version": "3.0.6", 142 | "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.0.6.tgz", 143 | "integrity": "sha1-4EqqnQW3o8ubD0B9BDdfBEcZA0c=", 144 | "requires": { 145 | "ansi-escapes": "^1.1.0", 146 | "chalk": "^1.0.0", 147 | "cli-cursor": "^2.1.0", 148 | "cli-width": "^2.0.0", 149 | "external-editor": "^2.0.1", 150 | "figures": "^2.0.0", 151 | "lodash": "^4.3.0", 152 | "mute-stream": "0.0.7", 153 | "run-async": "^2.2.0", 154 | "rx": "^4.1.0", 155 | "string-width": "^2.0.0", 156 | "strip-ansi": "^3.0.0", 157 | "through": "^2.3.6" 158 | } 159 | }, 160 | "is-fullwidth-code-point": { 161 | "version": "2.0.0", 162 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", 163 | "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" 164 | }, 165 | "is-stream": { 166 | "version": "1.1.0", 167 | "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", 168 | "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" 169 | }, 170 | "js-tokens": { 171 | "version": "4.0.0", 172 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 173 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 174 | }, 175 | "lodash": { 176 | "version": "4.17.20", 177 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", 178 | "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" 179 | }, 180 | "lodash.isempty": { 181 | "version": "4.4.0", 182 | "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", 183 | "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=" 184 | }, 185 | "lodash.times": { 186 | "version": "4.3.2", 187 | "resolved": "https://registry.npmjs.org/lodash.times/-/lodash.times-4.3.2.tgz", 188 | "integrity": "sha1-Ph8lZcQxdU1Uq1fy7RdBk5KFyh0=" 189 | }, 190 | "loose-envify": { 191 | "version": "1.4.0", 192 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 193 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 194 | "requires": { 195 | "js-tokens": "^3.0.0 || ^4.0.0" 196 | } 197 | }, 198 | "mimic-fn": { 199 | "version": "1.2.0", 200 | "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", 201 | "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==" 202 | }, 203 | "minimist": { 204 | "version": "1.2.0", 205 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", 206 | "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" 207 | }, 208 | "mute-stream": { 209 | "version": "0.0.7", 210 | "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", 211 | "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=" 212 | }, 213 | "node-fetch": { 214 | "version": "1.6.3", 215 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.6.3.tgz", 216 | "integrity": "sha1-3CNO3WSJmC1Y6PDbT2lQKavNjAQ=", 217 | "requires": { 218 | "encoding": "^0.1.11", 219 | "is-stream": "^1.0.1" 220 | } 221 | }, 222 | "object-assign": { 223 | "version": "4.1.1", 224 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 225 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" 226 | }, 227 | "onetime": { 228 | "version": "2.0.1", 229 | "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", 230 | "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", 231 | "requires": { 232 | "mimic-fn": "^1.0.0" 233 | } 234 | }, 235 | "opencollective": { 236 | "version": "1.0.3", 237 | "resolved": "https://registry.npmjs.org/opencollective/-/opencollective-1.0.3.tgz", 238 | "integrity": "sha1-ruY3K8KBRFg2kMPKja7PwSDdDvE=", 239 | "requires": { 240 | "babel-polyfill": "6.23.0", 241 | "chalk": "1.1.3", 242 | "inquirer": "3.0.6", 243 | "minimist": "1.2.0", 244 | "node-fetch": "1.6.3", 245 | "opn": "4.0.2" 246 | } 247 | }, 248 | "opn": { 249 | "version": "4.0.2", 250 | "resolved": "https://registry.npmjs.org/opn/-/opn-4.0.2.tgz", 251 | "integrity": "sha1-erwi5kTf9jsKltWrfyeQwPAavJU=", 252 | "requires": { 253 | "object-assign": "^4.0.1", 254 | "pinkie-promise": "^2.0.0" 255 | } 256 | }, 257 | "os-tmpdir": { 258 | "version": "1.0.2", 259 | "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", 260 | "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" 261 | }, 262 | "pinkie": { 263 | "version": "2.0.4", 264 | "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", 265 | "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" 266 | }, 267 | "pinkie-promise": { 268 | "version": "2.0.1", 269 | "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", 270 | "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", 271 | "requires": { 272 | "pinkie": "^2.0.0" 273 | } 274 | }, 275 | "prop-types": { 276 | "version": "15.7.2", 277 | "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", 278 | "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", 279 | "requires": { 280 | "loose-envify": "^1.4.0", 281 | "object-assign": "^4.1.1", 282 | "react-is": "^16.8.1" 283 | } 284 | }, 285 | "react-is": { 286 | "version": "16.13.1", 287 | "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", 288 | "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" 289 | }, 290 | "react-native-elements": { 291 | "version": "0.18.5", 292 | "resolved": "https://registry.npmjs.org/react-native-elements/-/react-native-elements-0.18.5.tgz", 293 | "integrity": "sha1-nZpYQ5X+pj+MrXkE+yHB6sfMY88=", 294 | "requires": { 295 | "lodash.isempty": "^4.4.0", 296 | "lodash.times": "^4.3.2", 297 | "opencollective": "^1.0.3", 298 | "prop-types": "^15.5.8" 299 | } 300 | }, 301 | "regenerator-runtime": { 302 | "version": "0.10.5", 303 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", 304 | "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=" 305 | }, 306 | "restore-cursor": { 307 | "version": "2.0.0", 308 | "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", 309 | "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", 310 | "requires": { 311 | "onetime": "^2.0.0", 312 | "signal-exit": "^3.0.2" 313 | } 314 | }, 315 | "run-async": { 316 | "version": "2.4.1", 317 | "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", 318 | "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==" 319 | }, 320 | "rx": { 321 | "version": "4.1.0", 322 | "resolved": "https://registry.npmjs.org/rx/-/rx-4.1.0.tgz", 323 | "integrity": "sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=" 324 | }, 325 | "safer-buffer": { 326 | "version": "2.1.2", 327 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 328 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 329 | }, 330 | "signal-exit": { 331 | "version": "3.0.3", 332 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", 333 | "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" 334 | }, 335 | "string-width": { 336 | "version": "2.1.1", 337 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", 338 | "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", 339 | "requires": { 340 | "is-fullwidth-code-point": "^2.0.0", 341 | "strip-ansi": "^4.0.0" 342 | }, 343 | "dependencies": { 344 | "ansi-regex": { 345 | "version": "3.0.0", 346 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", 347 | "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" 348 | }, 349 | "strip-ansi": { 350 | "version": "4.0.0", 351 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", 352 | "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", 353 | "requires": { 354 | "ansi-regex": "^3.0.0" 355 | } 356 | } 357 | } 358 | }, 359 | "strip-ansi": { 360 | "version": "3.0.1", 361 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", 362 | "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", 363 | "requires": { 364 | "ansi-regex": "^2.0.0" 365 | } 366 | }, 367 | "supports-color": { 368 | "version": "2.0.0", 369 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", 370 | "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" 371 | }, 372 | "through": { 373 | "version": "2.3.8", 374 | "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", 375 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" 376 | }, 377 | "tmp": { 378 | "version": "0.0.33", 379 | "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", 380 | "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", 381 | "requires": { 382 | "os-tmpdir": "~1.0.2" 383 | } 384 | } 385 | } 386 | } 387 | --------------------------------------------------------------------------------