├── 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 |
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 |
--------------------------------------------------------------------------------