by
13 | // default, and provide enter and leave animations, though these could be overridden if it makes
14 | // sense for your use case. A position: 'relative' style is also applied by default since the loading
15 | // effect requires position: 'absolute' on the child.
16 | //
17 | // This component defines a "duration" property that is used for both the enter and leave animation
18 | // durations.
19 | //
20 | // Use the property "opaque" if the children have opaque backgrounds. This will make the new element
21 | // come in 100% opacity and fade the old element out from on top of it. (Without this, opaque
22 | // elements end up bleeding the background behind the LoadingCrossfadeComponent through.)
23 |
24 | var React = require('react');
25 | var _ = require('lodash');
26 | //var VelocityTransitionGroup = require('velocity-transition-group');
27 | import { VelocityTransitionGroup } from 'velocity-react';
28 |
29 | var LoadingCrossfadeComponent = React.createClass({
30 | displayName: 'LoadingCrossfadeComponent',
31 |
32 | propTypes: {
33 | opaque: React.PropTypes.bool,
34 | duration: React.PropTypes.number,
35 | // At most 1 child should be supplied at a time, though the animation does correctly handle
36 | // elements moving in and out faster than the duration (so you can have 2 leaving elements
37 | // simultaneously, for example).
38 | children: React.PropTypes.element,
39 | },
40 |
41 | getDefaultProps: function () {
42 | return {
43 | duration: 350,
44 | };
45 | },
46 |
47 | render: function () {
48 | // We pull style out explicitly so that we can merge the position: 'relative' over any provided
49 | // value. position: 'relative' lets us absolutely-position the leaving child during the fade.
50 | var style = _.defaults((this.props.style || {}), { position: 'relative' });
51 |
52 | var transitionGroupProps = _.defaults(_.omit(this.props, _.keys(this.constructor.propTypes), 'style'), {
53 | component: 'div',
54 | style: style,
55 |
56 | enter: {
57 | animation: { opacity: 1 },
58 | duration: this.props.duration,
59 | style: {
60 | // If we're animating opaque backgrounds then we just render the new element under the
61 | // old one and fade out the old one. Without this, at e.g. the crossfade midpoint of
62 | // 50% opacity for old and 50% opacity for new, the parent background ends up bleeding
63 | // through 25%, which makes things look not smooth at all.
64 | opacity: this.props.opaque ? 1 : 0,
65 |
66 | // We need to clear out all the styles that "leave" puts on the element.
67 | position: 'relative',
68 | top: '',
69 | left: '',
70 | bottom: '',
71 | right: '',
72 | zIndex: '',
73 | },
74 | },
75 |
76 | leave: {
77 | animation: { opacity: 0 },
78 | duration: this.props.duration,
79 | style: {
80 | // 'absolute' so the 2 elements overlap for a crossfade
81 | position: 'absolute',
82 | top: 0,
83 | left: 0,
84 | bottom: 0,
85 | right: 0,
86 | zIndex: 1,
87 | },
88 | },
89 | });
90 |
91 | return React.createElement(VelocityTransitionGroup, transitionGroupProps, this.props.children);
92 | },
93 | });
94 |
95 | module.exports = LoadingCrossfadeComponent;
96 |
--------------------------------------------------------------------------------
/imports/ui/customers/CustomerEditForm.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import TextInput from '../components/TextInput.jsx';
4 | import DateInput from '../components/DateInput.jsx';
5 | import SelectInput from '../components/SelectInput.jsx';
6 |
7 |
8 | const CustomerEditForm = React.createClass({
9 | propTypes: {
10 | customer: React.PropTypes.object.isRequired,
11 | onChange: React.PropTypes.func.isRequired,
12 | onSave: React.PropTypes.func.isRequired,
13 | salesRegionOptions: React.PropTypes.array.isRequired,
14 | errors: React.PropTypes.object,
15 | isValid: React.PropTypes.bool
16 | },
17 |
18 | onSave(event) {
19 | event.preventDefault();
20 |
21 | this.props.onSave(this.props.customer);
22 | },
23 |
24 | onChange(event) {
25 | this.callOnChange(event.target.name, event.target.value);
26 | },
27 |
28 | onSelectChange(newValue) {
29 | this.callOnChange(newValue.name, newValue.selectedOption[newValue.valueKey]);
30 | },
31 |
32 | callOnChange(name, value) {
33 | // create a single row array with the data in
34 | this.props.onChange(this.props.customer, [{name, value}]);
35 | },
36 |
37 | render() {
38 | //console.log("CustomerEditComponent.render() props: ", this.props);
39 |
40 | let errors = {};
41 | if (this.props.errors) {
42 | errors = this.props.errors;
43 | }
44 |
45 | return (
46 |
107 | );
108 | }
109 | });
110 |
111 | module.exports = CustomerEditForm;
112 |
113 |
114 |
--------------------------------------------------------------------------------
/imports/api/orders/server/publications.js:
--------------------------------------------------------------------------------
1 | import Orders from '../order';
2 |
3 | const OrderPublicFields = {
4 | customerId: 1,
5 | customerName: 1,
6 | deliveryDate: 1,
7 | notes: 1,
8 | deliveryAddress1: 1,
9 | deliveryAddress2: 1,
10 | deliveryAddress3: 1,
11 | county: 1,
12 | postcode: 1,
13 | totalValue: 1,
14 | createdAt: 1,
15 | orderLines: [{
16 | _id: 1,
17 | productId: 1,
18 | description: 1,
19 | quantity: 1,
20 | unitPrice: 1,
21 | lineValue: 1,
22 | createdAt: 1
23 | }]
24 | }
25 |
26 | const OrdersListFields = {
27 | createdAt: 1,
28 | customerName: 1,
29 | totalValue: 1
30 | }
31 |
32 | Meteor.publish('Orders.public', function () {
33 | return Orders.find(
34 | {},
35 | {
36 | fields: OrdersListFields
37 | }
38 | );
39 | });
40 |
41 | Meteor.publish('Orders.topOrders', function (numberToReturn) {
42 | return Orders.find(
43 | {},
44 | {
45 | sort: {totalValue: -1},
46 | limit: numberToReturn,
47 | fields: OrdersListFields
48 | }
49 | );
50 | });
51 |
52 | Meteor.publish('Order.get', function (id) {
53 | //console.log("Order.get ", Orders.find({_id: id}).fetch());
54 | return Orders.find(
55 | {
56 | _id: id
57 | },
58 | {
59 | fields: OrderPublicFields
60 | }
61 | );
62 | });
63 |
64 | Meteor.publish('Order.customerTotals', function (customerId) {
65 | //console.log("Order.customerTotals ");
66 |
67 | var pipeline = [
68 | {$group: {_id: null, ordersTotalValue: {$sum: "$totalValue"}}}
69 | ];
70 |
71 | var result = Orders.aggregate(pipeline, {customerId});
72 | //console.log("Order.customerTotals:", JSON.stringify(result[0]), null, 2);
73 | //console.log("Order.customerTotals:", result);
74 |
75 | return result;
76 | });
77 |
78 | Meteor.publish('Orders.fullTextSearch', function (searchValue) {
79 | //console.log("Orders.fullTextSearch - "
80 | // + searchTerm + " - ", Orders.find({name: new RegExp(searchTerm)}).fetch());
81 |
82 | return Orders.find(
83 | { $text: {$search: searchValue} },
84 | {
85 | // `fields` is where we can add MongoDB projections. Here we're causing
86 | // each document published to include a property named `score`, which
87 | // contains the document's search rank, a numerical value, with more
88 | // relevant documents having a higher score.
89 | fields: {
90 | score: { $meta: "textScore" }
91 | },
92 | // This indicates that we wish the publication to be sorted by the
93 | // `score` property specified in the projection fields above.
94 | sort: {
95 | score: { $meta: "textScore" }
96 | }
97 | }
98 | );
99 | });
100 |
101 | Meteor.methods({
102 | 'Orders.fullTextSearch.method'({ searchValue }) {
103 |
104 | if (Meteor.isServer) {
105 | const results = Orders.find(
106 | {$text: {$search: searchValue}},
107 | {
108 | // `fields` is where we can add MongoDB projections. Here we're causing
109 | // each document published to include a property named `score`, which
110 | // contains the document's search rank, a numerical value, with more
111 | // relevant documents having a higher score.
112 | fields: {
113 | score: {$meta: "textScore"},
114 | // Only return the fields needed for the search results control:
115 | customerName: 1,
116 | createdAt: 1
117 | },
118 | // This indicates that we wish the publication to be sorted by the
119 | // `score` property specified in the projection fields above.
120 | sort: {
121 | score: {$meta: "textScore"}
122 | }
123 | }
124 | );
125 | //console.log('Orders.fullTextSearch results ', results.fetch());
126 |
127 | return results.fetch();
128 | }
129 | }
130 | });
--------------------------------------------------------------------------------
/imports/ui/sales/OrderContainer.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 |
4 | import OrderHeaderEdit from './OrderHeaderEdit.jsx';
5 | import OrderLinesList from './OrderLinesList.jsx';
6 | import SalesRegions from '../../api/sales-regions/sales-region';
7 |
8 | import { saveOrder, editOrder, selectOrder,
9 | selectNewOrder, editOrderLine, editOrderLineProduct,
10 | addNewOrderLine, deleteOrderLine, } from '../redux/order-actions.jsx';
11 |
12 |
13 | export const OrderContainer = React.createClass({
14 |
15 | componentWillMount() {
16 | //console.log("OrderContainer.componentWillMount()", this.props);
17 |
18 | const orderId = FlowRouter.getParam('_id');
19 |
20 | if (orderId) {
21 | this.sub = Meteor.subscribe('Order.get', orderId, this.setOrderInState);
22 | } else {
23 | this.props.selectNewOrder();
24 | }
25 |
26 | },
27 |
28 | setOrderInState() {
29 | //console.log("setOrderInState");
30 | this.props.selectOrder(FlowRouter.getParam('_id'));
31 | },
32 |
33 | componentWillUnmount() {
34 | if (this.sub) {
35 | this.sub.stop();
36 | }
37 | },
38 |
39 | shouldComponentUpdate() {
40 | //console.log("shouldComponentUpdate", this.sub);
41 | return (!this.sub || this.sub.ready);
42 | },
43 |
44 | render() {
45 | //console.log("OrderContainer.render()", this.props);
46 | if (this.sub && !this.sub.ready) {
47 | return (
Loading );
48 | }
49 |
50 | const pageHeader = (FlowRouter.getParam('_id')) ? "Sales Order" : "New Sales Order";
51 |
52 | return (
53 |
86 | );
87 | }
88 | });
89 |
90 | OrderContainer.propTypes = {
91 | order: PropTypes.object,
92 | onSave: PropTypes.func.isRequired,
93 | onChange: PropTypes.func.isRequired,
94 | selectOrder: PropTypes.func.isRequired,
95 | selectNewOrder: PropTypes.func.isRequired,
96 | editOrderLine: PropTypes.func.isRequired,
97 | editOrderLineProduct: PropTypes.func.isRequired,
98 | addNewOrderLine: PropTypes.func.isRequired,
99 | deleteOrderLine: PropTypes.func.isRequired
100 | };
101 |
102 | function mapStateToProps(state) {
103 | //console.log("OrderContainer.mapStateToProps", state);
104 | return {
105 | order: state.orderBeingEdited.order
106 | };
107 | }
108 |
109 | function mapDispatchToProps(dispatch) {
110 | //console.log("OrderContainer.mapDispatchToProps", OrderActions.orderSave)
111 | return;
112 | }
113 |
114 | export default connect(mapStateToProps, {
115 | onSave: saveOrder,
116 | onChange: editOrder,
117 | selectOrder,
118 | selectNewOrder,
119 | editOrderLine,
120 | editOrderLineProduct,
121 | addNewOrderLine,
122 | deleteOrderLine
123 |
124 | })(OrderContainer);
--------------------------------------------------------------------------------
/lib/collection-schema.js:
--------------------------------------------------------------------------------
1 | Schemas = {};
2 |
3 | Schemas.CustomerCompaniesSchema = new SimpleSchema({
4 |
5 | name: {
6 | type: String,
7 | max: 100,
8 | optional: false,
9 | label: "Customer name"
10 | },
11 |
12 | email: {
13 | type: String,
14 | max: 100,
15 | regEx: SimpleSchema.RegEx.Email,
16 | optional: true
17 | },
18 |
19 | postcode: {
20 | type: String,
21 | max: 10,
22 | optional: true
23 | },
24 |
25 | salesRegionId: {
26 | type: String,
27 | max: 100,
28 | optional: true,
29 | regEx: SimpleSchema.RegEx.Id,
30 | label: "Sales region"
31 | },
32 |
33 | nextContactDate: {
34 | type: Date,
35 | optional: true
36 | },
37 |
38 | ordersCount: {
39 | type: Number,
40 | optional: true,
41 | decimal: false,
42 | defaultValue: 0
43 | },
44 |
45 | ordersTotalValue: {
46 | type: Number,
47 | optional: true,
48 | decimal: true,
49 | defaultValue: 0
50 | },
51 |
52 | createdAt: {
53 | type: Date,
54 | optional: false,
55 | label: "Date created",
56 | defaultValue: new Date()
57 | }
58 |
59 |
60 | });
61 |
62 |
63 | Schemas.SalesRegionSchema = new SimpleSchema({
64 |
65 | name: {
66 | type: String,
67 | max: 100,
68 | optional: false
69 | },
70 |
71 | createdAt: {
72 | type: Date,
73 | optional: false,
74 | label: "Date created"
75 | }
76 | });
77 |
78 |
79 | Schemas.ProductSchema = new SimpleSchema({
80 |
81 | name: {
82 | type: String,
83 | max: 100,
84 | optional: false
85 | },
86 |
87 | price: {
88 | type: Number,
89 | optional: false,
90 | decimal: true
91 | },
92 |
93 | createdAt: {
94 | type: Date,
95 | optional: false,
96 | label: "Date created"
97 | }
98 | });
99 |
100 | Schemas.OrderLineSchema = new SimpleSchema({
101 |
102 | _id: {
103 | type: String,
104 | max: 100,
105 | optional: false
106 | },
107 |
108 | productId: {
109 | type: String,
110 | optional: true,
111 | regEx: SimpleSchema.RegEx.Id,
112 | label: "Product"
113 | },
114 |
115 | description: {
116 | type: String,
117 | optional: false,
118 | min: 1
119 | },
120 |
121 | quantity: {
122 | type: Number,
123 | optional: false
124 | },
125 |
126 | unitPrice: {
127 | type: Number,
128 | optional: false,
129 | decimal: true
130 | },
131 |
132 | lineValue: {
133 | type: Number,
134 | optional: true,
135 | decimal: true
136 | },
137 |
138 | createdAt: {
139 | type: Date,
140 | optional: false,
141 | label: "Date created"
142 | }
143 | });
144 |
145 | Schemas.OrderSchema = new SimpleSchema({
146 |
147 | customerId: {
148 | type: String,
149 | max: 100,
150 | regEx: SimpleSchema.RegEx.Id,
151 | label: "Customer",
152 | optional: true
153 | },
154 |
155 | customerName: {
156 | type: String,
157 | optional: true
158 | },
159 |
160 | deliveryDate: {
161 | type: Date,
162 | optional: true
163 | },
164 |
165 | notes: {
166 | type: String,
167 | optional: true
168 | },
169 |
170 | deliveryAddress1: {
171 | type: String,
172 | optional: false,
173 | min: 1
174 | },
175 |
176 | deliveryAddress2: {
177 | type: String,
178 | optional: true
179 | },
180 |
181 | deliveryAddress3: {
182 | type: String,
183 | optional: true
184 | },
185 |
186 | town: {
187 | type: String,
188 | optional: true
189 | },
190 |
191 | county: {
192 | type: String,
193 | optional: true
194 | },
195 |
196 | postcode: {
197 | type: String,
198 | optional: true,
199 | max: 10
200 | },
201 |
202 | totalValue: {
203 | type: Number,
204 | optional: true,
205 | decimal: true
206 | },
207 |
208 | createdAt: {
209 | type: Date,
210 | optional: false,
211 | label: "Date created"
212 | },
213 |
214 | orderLines: {
215 | type: [ Schemas.OrderLineSchema ],
216 | optional: true
217 | }
218 | });
219 |
220 | //export default Schemas;
--------------------------------------------------------------------------------
/imports/ui/redux/customer_actions.jsx:
--------------------------------------------------------------------------------
1 | // action creators are functions that take a param and return
2 | // an 'action' that is consumed by a reducer. This may seem like
3 | // unneeded boilerplate but it's **really** nice to have a file
4 | // with *all* possible ways to mutate the state of the app.
5 |
6 | import { trackCollection } from 'meteor/skinnygeek1010:flux-helpers';
7 | import Alert from 'react-s-alert';
8 |
9 | import { validateItemAndAddValidationResults } from '../../../lib/validation-helpers';
10 | import CustomerCompanies from '../../api/customers/customer-company';
11 | import { upsert, remove } from '../../api/customers/methods';
12 |
13 | //Redux
14 | Meteor.startup(function () { // work around files not being defined yet
15 | //console.log("Meteor.startup(function ()");
16 |
17 | if (Meteor.isClient) { // work around not having actions in /both folder
18 | //console.log("ActionCreators Meteor.startup isClient");
19 | // trigger action when this changes
20 | trackCollection(CustomerCompanies, customersCollectionChanged);
21 | }
22 | });
23 |
24 |
25 | // used when a mongo customers collection changes
26 | export function customersCollectionChanged(newDocs) {
27 | //console.log("Actions.customersCollectionChanged ", newDocs);
28 | return (dispatch, getState) => {
29 | dispatch({
30 | type: 'CUSTOMERS_COLLECTION_CHANGED',
31 | collection: newDocs
32 | });
33 | }
34 | };
35 |
36 |
37 | // doesn't return payload because our collection watcher
38 | // will send a CHANGED action and update the store
39 | export function saveCustomer(customer) {
40 | //console.log("saveCustomer: ", customer);
41 | return (dispatch, getState) => {
42 |
43 | // call the method for upserting the data
44 | upsert.call({
45 | customerId: customer._id,
46 | data: customer
47 | }, (err, res) => {
48 | if (err) {
49 | // TODO call FAILED action on error
50 | console.log("error saving customer", err.message);
51 | Alert.error("error saving customer " + err.message);
52 | } else {
53 | FlowRouter.go("/");
54 | Alert.success("Save successful");
55 | dispatch({
56 | type: 'SAVE_CUSTOMER'
57 | });
58 | }
59 | });
60 | }
61 | }
62 |
63 | function dispatchCustomerChange(customer, newValues) {
64 | //console.log("inner");
65 | return {
66 | type: 'EDIT_CUSTOMER',
67 | customer
68 | };
69 | }
70 |
71 | export function editCustomer(customer, newValues) {
72 | //console.log("Actions.editCustomer() event.target:" + newValues);
73 | return (dispatch, getState) => {
74 | const customer = _.clone(getState().userInterface.customerBeingEdited);
75 |
76 | // loop each change and apply to our clone
77 | for (let newValue of newValues) {
78 | customer[newValue.name] = newValue.value;
79 | }
80 |
81 | // validate and set error messages
82 | validateItemAndAddValidationResults(customer, Schemas.CustomerCompaniesSchema);
83 |
84 | dispatch({
85 | type: 'EDIT_CUSTOMER',
86 | customer
87 | });
88 | }
89 | }
90 |
91 | function loadCustomerToEdit(customerId) {
92 | //console.log("loadCustomerToEdit");
93 | const customer = CustomerCompanies.findOne({_id: customerId})
94 | //console.log("loadCustomerToEdit ", customer);
95 |
96 | // perform initial validation and set error messages
97 | validateItemAndAddValidationResults(customer, Schemas.CustomerCompaniesSchema);
98 |
99 | return {
100 | type: 'SELECT_CUSTOMER',
101 | customer
102 | };
103 | }
104 |
105 | export function selectCustomer(customerId) {
106 | //console.log("Actions.selectCustomer: " + customerId.toString());
107 | return (dispatch, getState) => {
108 | dispatch(loadCustomerToEdit(customerId));
109 | }
110 | };
111 |
112 | export function selectNewCustomer() {
113 | //console.log("Actions.selectNewCustomer ")
114 | return (dispatch, getState) => {
115 |
116 | const customer = {
117 | name: "",
118 | email: "",
119 | postcode: "",
120 | salesRegionId: "",
121 | nextContactDate: new Date(),
122 | createdAt: new Date(),
123 | errors: {}
124 | };
125 |
126 | dispatch({
127 | type: 'SELECT_CUSTOMER',
128 | customer
129 | });
130 | }
131 | };
132 |
133 |
--------------------------------------------------------------------------------
/imports/ui/sales/OrderLineEdit.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import accounting from 'accounting';
3 |
4 | import AsyncSelectInput from '../components/AsyncSelectInput.jsx';
5 | import NumberInput from '../components/NumberInput.jsx';
6 | import GridRow from '../components/grid/GridRow.jsx'
7 | import GridColumn from '../components/grid/GridColumn.jsx'
8 |
9 | import Products from '../../api/products/products';
10 |
11 |
12 | const OrderLineEdit = React.createClass({
13 | propTypes: {
14 | orderLine: React.PropTypes.object,
15 | onChange: React.PropTypes.func.isRequired,
16 | onProductChange: React.PropTypes.func.isRequired,
17 | deleteOrderLine: React.PropTypes.func,
18 | errors: React.PropTypes.object.isRequired
19 | },
20 |
21 | getInitialState() {
22 | //console.log("OrderLineEdit.getInitialState", this.props);
23 |
24 | return {
25 | errors: {},
26 | isValid: false
27 | };
28 | },
29 |
30 | handleChange(event) {
31 | const field = event.target.name;
32 | const value = event.target.value;
33 |
34 | this.props.onChange(this.props.orderLine._id, field, value);
35 | },
36 |
37 | handleProductChange(selectedItem) {
38 | this.props.onProductChange(this.props.orderLine._id, selectedItem);
39 | },
40 |
41 | deleteLine() {
42 | this.props.deleteOrderLine(this.props.orderLine._id);
43 | },
44 |
45 | getProducts: function getProducts(input) {
46 | //console.log("OrderHeaderEdit.getProducts()", input);
47 | const handle = Meteor.subscribe('Products.searchByName', input);
48 | return Products.find().fetch();
49 | },
50 |
51 | render() {
52 | //console.log("OrderLineEdit props: ", this.props);
53 |
54 | const value = {
55 | _id: this.props.orderLine.productId ? this.props.orderLine.productId : null,
56 | name: this.props.orderLine.description
57 | };
58 |
59 |
60 | let errors = {};
61 | if (this.props.errors) {
62 | errors = this.props.errors;
63 | }
64 |
65 | // only show the delete button if we are passed in a delete method
66 | let deleteButton;
67 | if (this.props.deleteOrderLine) {
68 | deleteButton =
69 |
;
76 | }
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
94 |
95 |
96 |
97 |
104 |
105 |
106 |
107 |
116 |
117 |
118 |
119 |
120 | {accounting.formatMoney(this.props.orderLine.unitPrice * this.props.orderLine.quantity, '£')}
121 |
122 |
123 |
124 |
125 | {deleteButton}
126 |
127 |
128 |
129 | );
130 | }
131 | });
132 |
133 | export default OrderLineEdit;
134 |
--------------------------------------------------------------------------------
/imports/startup/server/fixtures.js:
--------------------------------------------------------------------------------
1 | import Products from '../../api/products/products';
2 | import Orders from '../../api/orders/order';
3 | import CustomerCompanies from '../../api/customers/customer-company';
4 | import SalesRegions from '../../api/sales-regions/sales-region';
5 |
6 |
7 | // if the database is empty on server start, create some sample data.
8 | Meteor.startup(() => {
9 | //console.log("fixtures Meteor.startup");
10 |
11 | // Add default admin account
12 | if (Meteor.users.find().count() === 0) {
13 | Accounts.createUser({
14 | username: 'default@admin',
15 | email: 'default@admin.com',
16 | password: 'default@admin'
17 | });
18 | }
19 |
20 |
21 | CustomerCompanies._ensureIndex({"name":"text", "email":"text", "postcode":"text"});
22 | //In Meteor Mongo: Orders._dropIndexes();
23 | Orders._ensureIndex({"customerName":"text", "postcode":"text", "orderLines.description":"text"});
24 | //In Meteor Mongo: Orders.createIndex({customerName:"text", postcode:"text", "orderLines.description":"text"});
25 | Products._ensureIndex({"name":"text"});
26 |
27 |
28 | if (CustomerCompanies.find().count() === 0) {
29 | const data = [
30 | {
31 | name: "Smiths Fabrication Ltd",
32 | email: "info@smiths.com",
33 | postcode: "OX10 4RT"
34 | },
35 | {
36 | name: "Bob's Bricks",
37 | email: "sales@bobsbricks.co.uk",
38 | postcode: "BR1 3EY"
39 | },
40 | {
41 | name: 'Parkers & Co',
42 | email: "info@parkers.co",
43 | postcode: "W1 8QT"
44 | }
45 | ];
46 |
47 | let timestamp = (new Date()).getTime();
48 |
49 | data.forEach((customer) => {
50 | CustomerCompanies.insert({
51 | name: customer.name,
52 | email: customer.email,
53 | postcode: customer.postcode,
54 | createdAt: new Date(timestamp)
55 | });
56 |
57 | //console.log("added customer: ", customer);
58 | });
59 | }
60 |
61 |
62 | if (SalesRegions.find().count() === 0) {
63 | const data = [
64 | {
65 | name: "North West"
66 | },
67 | {
68 | name: "North East"
69 | },
70 | {
71 | name: "Scotland"
72 | },
73 | {
74 | name: "South East"
75 | },
76 | {
77 | name: "Wales"
78 | },
79 | {
80 | name: "Southern Ireland"
81 | },
82 | {
83 | name: "Nothern Ireland"
84 | },
85 | {
86 | name: "South West"
87 | },
88 | {
89 | name: 'London'
90 | }
91 | ];
92 |
93 | let timestamp = (new Date()).getTime();
94 |
95 | data.forEach((item) => {
96 | SalesRegions.insert({
97 | name: item.name,
98 | createdAt: new Date(timestamp)
99 | });
100 |
101 | //console.log("added customer: ", customer);
102 | });
103 | }
104 |
105 |
106 |
107 |
108 | if (Products.find().count() === 0) {
109 | const data = [
110 | {
111 | name: "Olive-spantles (jigged & onioned)",
112 | price: 35
113 | },
114 | {
115 | name: "Grommet",
116 | price: 12.60
117 | },
118 | {
119 | name: "Grollings",
120 | price: 35
121 | },
122 | {
123 | name: "Copper pipe",
124 | price: 18.35
125 | },
126 | {
127 | name: "Fleeling wire (coaxial)",
128 | price: 4.30
129 | },
130 | {
131 | name: "Gruddock paper",
132 | price: 1.95
133 | },
134 | {
135 | name: "Bevelled spill-trunion",
136 | price: 18.40
137 | },
138 | {
139 | name: "Satchel-arm",
140 | price: 35
141 | },
142 | {
143 | name: 'Clip-jawed double lock brace',
144 | price: 3.99
145 | }
146 | ];
147 |
148 | let timestamp = (new Date()).getTime();
149 |
150 | data.forEach((item) => {
151 | Products.insert({
152 | name: item.name,
153 | price: item.price,
154 | createdAt: new Date(timestamp)
155 | });
156 |
157 | //console.log("added customer: ", customer);
158 | });
159 | }
160 | });
161 |
--------------------------------------------------------------------------------
/client/css/react-selectCOPY.min.css:
--------------------------------------------------------------------------------
1 | .Select,.Select-control{position:relative}.Select,.Select div,.Select input,.Select span{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.Select.is-disabled>.Select-control{background-color:#f6f6f6}.Select.is-disabled .Select-arrow-zone{cursor:default;pointer-events:none}.Select-control{background-color:#fff;border-radius:4px;border:1px solid #ccc;color:#333;cursor:default;display:table;height:36px;outline:0;overflow:hidden;width:100%}.is-searchable.is-focused:not(.is-open)>.Select-control,.is-searchable.is-open>.Select-control{cursor:text}.Select-placeholder,.Select-value{left:0;position:absolute;top:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.Select-control:hover{box-shadow:0 1px 0 rgba(0,0,0,.06)}.is-open>.Select-control{border-bottom-right-radius:0;border-bottom-left-radius:0;background:#fff;border-color:#b3b3b3 #ccc #d9d9d9}.is-open>.Select-control>.Select-arrow{border-color:transparent transparent #999;border-width:0 5px 5px}.is-focused:not(.is-open)>.Select-control{border-color:#08c #0099e6 #0099e6;box-shadow:inset 0 1px 2px rgba(0,0,0,.1),0 0 5px -1px rgba(0,136,204,.5)}.Select-placeholder{bottom:0;color:#aaa;line-height:34px;padding-left:10px;padding-right:10px;right:0}.has-value>.Select-control>.Select-placeholder{color:#333}.Select-value{color:#aaa;padding:8px 52px 8px 10px;right:-15px}.Select-arrow-zone,.Select-clear-zone,.Select-loading,.Select-loading-zone{position:relative;vertical-align:middle}.has-value>.Select-control>.Select-value{color:#333}.Select-input{height:34px;padding-left:10px;padding-right:10px;vertical-align:middle}.Select-input>input{background:none;border:0;box-shadow:none;cursor:default;display:inline-block;font-family:inherit;font-size:inherit;height:34px;margin:0;outline:0;padding:0;-webkit-appearance:none}.is-focused .Select-input>input{cursor:text}.Select-control:not(.is-searchable)>.Select-input{outline:0}.Select-loading-zone{cursor:pointer;display:table-cell;text-align:center;width:16px}.Select-loading{-webkit-animation:Select-animation-spin .4s infinite linear;-o-animation:Select-animation-spin .4s infinite linear;animation:Select-animation-spin .4s infinite linear;width:16px;height:16px;box-sizing:border-box;border-radius:50%;border:2px solid #ccc;border-right-color:#333;display:inline-block}.Select-clear-zone{-webkit-animation:Select-animation-fadeIn .2s;-o-animation:Select-animation-fadeIn .2s;animation:Select-animation-fadeIn .2s;color:#999;cursor:pointer;display:table-cell;text-align:center;width:17px}.Select-clear-zone:hover{color:#D0021B}.Select-clear{display:inline-block;font-size:18px;line-height:1}.Select--multi .Select-clear-zone{width:17px}.Select-arrow-zone{cursor:pointer;display:table-cell;text-align:center;width:25px;padding-right:5px}.Select-arrow{border-color:#999 transparent transparent;border-style:solid;border-width:5px 5px 2.5px;display:inline-block;height:0;width:0}.Select-arrow-zone:hover>.Select-arrow,.is-open .Select-arrow{border-top-color:#666}@-webkit-keyframes Select-animation-fadeIn{from{opacity:0}to{opacity:1}}@keyframes Select-animation-fadeIn{from{opacity:0}to{opacity:1}}.Select-menu-outer{border-bottom-right-radius:4px;border-bottom-left-radius:4px;background-color:#fff;border:1px solid #ccc;border-top-color:#e6e6e6;box-shadow:0 1px 0 rgba(0,0,0,.06);box-sizing:border-box;margin-top:-1px;max-height:200px;position:absolute;top:100%;width:100%;z-index:1000;-webkit-overflow-scrolling:touch}.Select-menu{max-height:198px;overflow-y:auto}.Select-option{box-sizing:border-box;color:#666;cursor:pointer;display:block;padding:8px 10px}.Select-option:last-child{border-bottom-right-radius:4px;border-bottom-left-radius:4px}.Select-option.is-focused{background-color:#f2f9fc;color:#333}.Select-option.is-disabled{color:#ccc;cursor:not-allowed}.Select-noresults,.Select-search-prompt,.Select-searching{box-sizing:border-box;color:#999;cursor:default;display:block;padding:8px 10px}.Select--multi .Select-input{vertical-align:middle;margin-left:10px;padding:0}.Select--multi.has-value .Select-input,.Select-item{margin-left:5px}.Select-item{background-color:#f2f9fc;border-radius:2px;border:1px solid #c9e6f2;color:#08c;display:inline-block;font-size:.9em;margin-top:5px;vertical-align:top}.Select-item-icon,.Select-item-label{display:inline-block;vertical-align:middle}.Select-item-label{border-bottom-right-radius:2px;border-top-right-radius:2px;cursor:default;padding:2px 5px}.Select-item-label .Select-item-label__a{color:#08c;cursor:pointer}.Select-item-icon{cursor:pointer;border-bottom-left-radius:2px;border-top-left-radius:2px;border-right:1px solid #c9e6f2;padding:1px 5px 3px}.Select-item-icon:focus,.Select-item-icon:hover{background-color:#ddeff7;color:#0077b3}.Select-item-icon:active{background-color:#c9e6f2}.Select--multi.is-disabled .Select-item{background-color:#f2f2f2;border:1px solid #d9d9d9;color:#888}.Select--multi.is-disabled .Select-item-icon{cursor:not-allowed;border-right:1px solid #d9d9d9}.Select--multi.is-disabled .Select-item-icon:active,.Select--multi.is-disabled .Select-item-icon:focus,.Select--multi.is-disabled .Select-item-icon:hover{background-color:#f2f2f2}@keyframes Select-animation-spin{to{transform:rotate(1turn)}}@-webkit-keyframes Select-animation-spin{to{-webkit-transform:rotate(1turn)}}
--------------------------------------------------------------------------------
/imports/ui/redux/reducers.jsx:
--------------------------------------------------------------------------------
1 | // reducers allow you to 'slice' off a part of the single state object which
2 | // lets you think about the domain in a smaller picture. You could use one
3 | // reducer in a small app like this but in large apps this reducer could be
4 | // several hundred lines. See store.jsx to see how these reducers get 'combined'
5 | // into one single app state. We'll use two reducers, one for transient state
6 | // that the UI uses (selected id,name) and one for data (coming from Mongo)
7 |
8 | import { combineReducers } from 'redux';
9 |
10 | Reducers = {};
11 |
12 | let initialInterfaceState = {
13 | customerBeingEdited: {},
14 | orderList: {
15 | expanded: false
16 | },
17 | customerList: {
18 | expanded: false
19 | }
20 | };
21 |
22 | // helper to *copy* old state and merge new data with it
23 | function merge(oldState, newState) {
24 | return _.extend({}, oldState, newState);
25 | }
26 |
27 | // these reducers *must* be pure to use time-travel dev-tools
28 | // never directly mutate the `state` param, use merge instead
29 |
30 | const userInterface = function userInterface(state = initialInterfaceState, action) {
31 | //console.log("reducers.userInterface action:", {state, action});
32 |
33 | switch (action.type) {
34 | case 'SELECT_CUSTOMER':
35 | //console.log("userInterface SELECT_CUSTOMER, action:", action);
36 |
37 | return merge(state, {
38 | customerBeingEdited: action.customer
39 | });
40 | case 'EDIT_CUSTOMER':
41 | //console.log("userInterface EDIT_CUSTOMER, customer:", state.customerBeingEdited);
42 |
43 | // merge in our newly edited data
44 | return merge(state, { customerBeingEdited: action.customer });
45 | case 'TOGGLE_ORDER_LIST_EXPANDED':
46 | const orderList = _.clone(state.orderList);
47 | orderList.expanded = !orderList.expanded;
48 | return merge(state, { orderList });
49 | case 'TOGGLE_CUSTOMER_LIST_EXPANDED':
50 | const customerList = _.clone(state.customerList);
51 | customerList.expanded = !customerList.expanded;
52 | return merge(state, { customerList });
53 | default:
54 | return state;
55 | }
56 | };
57 |
58 |
59 | let initialOrderState = {
60 | order: { status: "initial state"}
61 | };
62 |
63 | const orderBeingEdited = function orderBeingEdited(state = initialOrderState, action) {
64 | //console.log("reducers.userInterface action:", {state, action});
65 |
66 | switch (action.type) {
67 | case 'SELECT_ORDER':
68 | //console.log("orderBeingEdited SELECT_ORDER, action:", action);
69 |
70 | return merge(state, {
71 | order: action.order
72 | });
73 | case 'EDIT_ORDER':
74 | //console.log("orderBeingEdited EDIT_ORDER, order:", action.order);
75 |
76 | // merge in our newly edited data
77 | return merge(state, { order: action.order });
78 | default:
79 | return state;
80 | }
81 | };
82 |
83 |
84 | // using the ES6 default params instead of the manual check like above
85 |
86 | const customer = function customer(state = {}, action) {
87 | //console.log("reducers.customer", {state, action});
88 |
89 | switch (action.type) {
90 | case 'SAVE_CUSTOMER':
91 | // normally in redux you would update and merge state here but
92 | // since have minimongo to do that for us we'll just wait for the
93 | // flux-helper to fire a COLLECTION_CHANGED dispatch after the
94 | // increment update. Since we're doing that we'll just return the old
95 | // state to prevent the UI from re-rendering twice.
96 | return state;
97 | case 'CUSTOMERS_COLLECTION_CHANGED':
98 | //console.log("reducers.customer CUSTOMERS_COLLECTION_CHANGED", {state, action});
99 |
100 | // we don't have to merge the single doc that changes since minimongo
101 | // keeps the entire cache for us. We'll just return the new minimongo state
102 | // We *could* also return another fetch if sorting wasn't so easy here
103 | //let docs = _.clone(action.collection); // clone to prevent mutating action!!
104 | //return docs[0]; //.sort((a,b) => b.score - a.score);
105 | //return _.clone(action.collection);
106 | return state;
107 | default:
108 | return state;
109 | }
110 | };
111 |
112 |
113 | const order = function order(state = {}, action) {
114 | //console.log("reducers.order", {state, action});
115 |
116 | switch (action.type) {
117 | case 'SAVE_ORDER':
118 | return state;
119 | case 'ORDERS_COLLECTION_CHANGED':
120 | //console.log("reducers.order ORDERS_COLLECTION_CHANGED", {state, action});
121 | return state;
122 | default:
123 | return state;
124 | }
125 | };
126 |
127 |
128 | const rootReducer = combineReducers({
129 | userInterface,
130 | customer,
131 | order,
132 | orderBeingEdited
133 | });
134 |
135 | export default rootReducer;
136 |
--------------------------------------------------------------------------------
/imports/ui/app/Layout.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
3 | import { Provider } from 'react-redux';
4 | import Alert from 'react-s-alert';
5 |
6 | //import debugOnly from 'meteor/msavin:debugonly'; // not working on web deploy yet by the looks of things
7 |
8 | import DevTools from '../redux/DevTools.jsx';
9 | import GlobalSearch from '../search/GlobalSearch.jsx';
10 |
11 | import store from '../redux/store.jsx';
12 |
13 | import AccountsButton from '../security/accounts-button.jsx';
14 |
15 |
16 | Meteor.subscribe("SalesRegions.All");
17 | Meteor.subscribe("Orders.All");
18 |
19 |
20 | const ContentContainer = React.createClass({
21 | render() {
22 | //console.log("ContentContainer.render()", this.props.children);
23 |
24 | return (
25 |
26 |
32 | {React.cloneElement(this.props.children, {
33 | key: this.props.children.key
34 | })}
35 |
36 |
37 | );
38 | }
39 |
40 | });
41 |
42 | function renderDevTools() {
43 | //if (debugOnly) {
44 | return
;
45 | //}
46 | }
47 |
48 | export const Layout = ({content}) => (
49 |
50 |
51 |
52 |
53 |
54 | {/* Navigation */}
55 |
56 | {/* Nav Header */}
57 |
58 |
60 | Toggle navigation
61 |
62 |
63 |
64 |
65 |
simple crm
66 |
67 |
68 |
69 | {/* Left Side Nav - The Add Buttons */}
70 |
81 |
82 | {/* Left Side Nav - Account buttons */}
83 |
86 | {/* /.navbar-header */}
87 |
88 | {/* This is the sidebar. It's inside the top nav somehow */}
89 |
90 |
91 |
106 |
107 | {/* /.sidebar-collapse */}
108 |
109 | {/* /.navbar-static-side */}
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | {content}
119 |
120 |
121 |
122 | {/* /#page-wrapper */}
123 |
124 |
130 | { renderDevTools() }
131 |
132 |
133 | );
--------------------------------------------------------------------------------
/imports/startup/client/routes.jsx:
--------------------------------------------------------------------------------
1 |
2 | /*
3 | * General note on passing data to templates: Do not subscribe to
4 | * data in the routes, this is an anti-pattern,
5 | * https://kadira.io/academy/meteor-routing-guide/content/subscriptions-and-data-management
6 | * */
7 |
8 | import React from 'react';
9 | import ReactDOM from 'react-dom';
10 | import { mount } from 'react-mounter';
11 |
12 | import { Layout } from './../../ui/app/Layout.jsx';
13 | import Dashboard from '../../ui/dashboard/Dashboard.jsx';
14 | import AppNotFound from './../../ui/app/app-not-found.jsx';
15 | import ProductsListWrapper from './../../ui/products/products-list-wrapper.jsx';
16 | import Test1 from './../../ui/app/test1.jsx';
17 | import Test2 from './../../ui/app/test2.jsx';
18 | import CustomerContainer from './../../ui/customers/CustomerContainer.jsx';
19 | import AllCustomersContainer from './../../ui/customers/AllCustomersContainer.jsx';
20 | import OrderContainer from './../../ui/sales/OrderContainer.jsx';
21 | import AllOrdersContainer from './../../ui/sales/AllOrdersContainer.jsx';
22 | import Login from './../../ui/security/login.jsx';
23 | import Register from './../../ui/security/register.jsx';
24 |
25 | import store from './../../ui/redux/store.jsx';
26 |
27 |
28 | // Redirect unauthed users to login page
29 | const authenticatedRedirect = () => {
30 | if (!Meteor.loggingIn() && !Meteor.userId()) {
31 | FlowRouter.go( 'Login' );
32 | }
33 | };
34 |
35 | // Set group using previous re-direct
36 | const authenticatedRoutes = FlowRouter.group({
37 | name: 'authenticated',
38 | triggersEnter: [ authenticatedRedirect ]
39 | });
40 |
41 | // Public Login page
42 | FlowRouter.route("/login", {
43 | name: "Login",
44 | action() {
45 | //console.log("route ", this.name);
46 | mount(Login);
47 | }
48 | });
49 |
50 | authenticatedRoutes.route("/register", {
51 | name: "Register",
52 | action() {
53 | //console.log("route ", this.name);
54 | mount(Layout, {
55 | content: (
)
56 | });
57 |
58 | }
59 | });
60 |
61 |
62 | authenticatedRoutes.route("/", {
63 | name: "Home",
64 | action() {
65 | //console.log("route ", this.name);
66 | mount(Layout, {
67 | content: (
)
68 | });
69 | }
70 | });
71 |
72 | authenticatedRoutes.route('/customers/:_id', {
73 | name: 'CustomerCompany.edit',
74 | action() {
75 | //console.log("route ", this.name);
76 | mount(Layout, {
77 | content: (
)
78 | });
79 | }
80 | });
81 |
82 | authenticatedRoutes.route("/addCustomer", {
83 | name: "addCustomer",
84 | action() {
85 | //console.log("route ", this.name);
86 | mount(Layout, {
87 | content: ()
88 | });
89 | }
90 | });
91 |
92 | authenticatedRoutes.route("/allCustomers", {
93 | name: "allCustomers",
94 | action() {
95 | //console.log("route ", this.name);
96 | mount(Layout, {
97 | content: ()
98 | });
99 | }
100 | });
101 |
102 | authenticatedRoutes.route('/products/', {
103 | name: 'productsList',
104 | action() {
105 | //console.log("route ", this.name);
106 | mount(Layout, {
107 | content: ( )
108 | });
109 | }
110 | });
111 |
112 | FlowRouter.notFound = {
113 | name: "notFoundRoute",
114 | action() {
115 | //console.log("route ", this.name);
116 | mount(Layout, {
117 | content: ( )
118 | });
119 | }
120 | };
121 |
122 | authenticatedRoutes.route('/orders/:_id', {
123 | name: 'Order.edit',
124 | action() {
125 | //console.log("route ", this.name);
126 | mount(Layout, {
127 | content: ()
128 | });
129 | }
130 | });
131 |
132 | authenticatedRoutes.route("/addOrder", {
133 | name: "addOrder",
134 | action() {
135 | //console.log("route ", this.name);
136 | mount(Layout, {
137 | content: ()
138 | });
139 | }
140 | });
141 |
142 | authenticatedRoutes.route("/allOrders", {
143 | name: "allOrders",
144 | action() {
145 | //console.log("route ", this.name);
146 | mount(Layout, {
147 | content: ()
148 | });
149 | }
150 | });
151 |
152 | authenticatedRoutes.route("/test1", {
153 | name: "test1",
154 | action() {
155 | //console.log("route ", this.name);
156 | mount(Layout, {
157 | content: ( )
158 | });
159 | }
160 | });
161 |
162 | authenticatedRoutes.route("/test2", {
163 | name: "test2",
164 | action() {
165 | //console.log("route ", this.name);
166 | mount(Layout, {
167 | content: ( )
168 | });
169 | }
170 | });
171 |
172 | authenticatedRoutes.route("/sb", {
173 | name: "sb",
174 | action() {
175 | //console.log("route ", this.name);
176 | mount(Layout2, {
177 | content: ( )
178 | });
179 | }
180 | });
181 |
--------------------------------------------------------------------------------
/imports/ui/sales/OrderHeaderEdit.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Autosuggest from 'react-autosuggest';
3 | import accounting from 'accounting';
4 |
5 | import AsyncSelectInput from '../components/AsyncSelectInput.jsx';
6 | import TextInput from '../components/TextInput.jsx';
7 | import DateInput from '../components/DateInput.jsx';
8 |
9 | import CustomerCompanies from '../../api/customers/customer-company';
10 |
11 | const OrderHeaderEdit = React.createClass({
12 | propTypes: {
13 | order: React.PropTypes.object.isRequired,
14 | onChange: React.PropTypes.func.isRequired,
15 | onSave: React.PropTypes.func.isRequired,
16 | errors: React.PropTypes.object,
17 | isValid: React.PropTypes.bool
18 | },
19 |
20 | getCustomers: function getCustomers(input) {
21 | //console.log("OrderHeaderEdit.getCustomers()", input);
22 | const handle = Meteor.subscribe('CustomerCompanies.searchByName', input);
23 | return CustomerCompanies.find().fetch();
24 | },
25 |
26 | onSave(event) {
27 | event.preventDefault();
28 |
29 | this.props.onSave(this.props.order);
30 | },
31 |
32 | onChange(event) {
33 | //console.log("OrderEditForm.onChange() name: " + event.target.name + " value: ", event.target.value);
34 |
35 | //this.callOnChange(event.target.name, event.target.value);
36 | this.props.onChange(this.props.order, [ { name: event.target.name, value: event.target.value} ] );
37 | },
38 |
39 | onSelectChange(newValue) {
40 | //console.log("OrderEditForm.onSelectChange() name: " + newValue.name + " value: ", newValue);
41 |
42 | this.props.onChange(this.props.order,
43 | [
44 | // need to pass both the id and the label values out for denormalization
45 | { name: "customerId", value: newValue.selectedOption[newValue.valueKey]},
46 | { name: "customerName", value: newValue.selectedOption[newValue.labelKey]}
47 | ]
48 | );
49 |
50 | },
51 |
52 | render() {
53 | //console.log("OrderHeaderEdit.render() - props: ", this.props);
54 |
55 | const value = {
56 | _id: this.props.order.customerId ? this.props.order.customerId : '',
57 | name: this.props.order.customerName
58 | };
59 |
60 | let errors = {};
61 | if (this.props.errors) {
62 | errors = this.props.errors;
63 | }
64 |
65 | return (
66 |
67 |
68 |
85 |
86 |
87 |
88 |
91 |
92 |
95 |
96 |
98 |
99 |
101 |
102 |
104 |
105 |
107 |
108 |
109 |
110 | Total value:
111 | {accounting.formatMoney(this.props.order.totalValue, "£")}
112 |
113 |
114 |
115 |
Cancel
116 |
117 |
119 |
120 |
121 | );
122 | }
123 | });
124 |
125 | export default OrderHeaderEdit;
126 |
127 |
--------------------------------------------------------------------------------
/imports/ui/components/AsyncSelectInput.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Select from 'react-select';
3 | var humanize = require('string-humanize');
4 |
5 | const AsyncSelectInput = React.createClass({
6 | propTypes: {
7 | name: React.PropTypes.string.isRequired,
8 | label: React.PropTypes.string,
9 | // The value needs to be an object with two properties called the same as the valueKey and the labelKey
10 | value: React.PropTypes.object.isRequired,
11 | loadOptions: React.PropTypes.func.isRequired,
12 | onChange: React.PropTypes.func.isRequired,
13 | valueKey: React.PropTypes.string.isRequired,
14 | labelKey: React.PropTypes.string.isRequired,
15 | error: React.PropTypes.string,
16 | hideLabel: React.PropTypes.bool
17 | },
18 |
19 | onChangeHandler(selectedOption) {
20 | //console.log("AsyncSelectInput.onChangeHandler selectedOption ",
21 | // selectedOption[this.props.valueKey] + " " + selectedOption[this.props.labelKey])
22 |
23 | this.props.onChange({
24 | name: this.props.name,
25 | labelKey: this.props.labelKey,
26 | valueKey: this.props.valueKey,
27 | // selectedOption is null when the user presses the 'x' button
28 | selectedOption
29 | });
30 | },
31 |
32 | loadOptions(input, callback) {
33 | //console.log("OrderHeaderEdit.loadOptions() ", input);
34 |
35 | var data = {
36 | options: this.props.loadOptions(input),
37 | // this tells the select control whether this is the complete dataset of all possible options,
38 | // which in turn tells the control whether to bother re-querying the datasource or instead
39 | // just to use it's cached dataset.
40 | complete: false
41 | };
42 |
43 | setTimeout(function () {
44 | //console.log("setTimeout", input);
45 | callback(null, data);
46 | }, 500);
47 | },
48 |
49 | // The value needs to be an object with two properties called the same as the valueKey and the labelKey
50 | getValue() {
51 | //console.log("AsyncSelectInput.getValue(): this.props.value = ",
52 | // this.props.value[this.props.valueKey] + " - " + this.props.value[this.props.labelKey]);
53 |
54 | return {
55 | [this.props.valueKey]: this.props.value[this.props.valueKey],
56 | [this.props.labelKey]: this.props.value[this.props.labelKey]
57 | };
58 | },
59 |
60 | renderLabel() {
61 | if (!this.props.hideLabel) {
62 | return (
63 | {this.props.label ? this.props.label : humanize(this.props.name)}
64 | );
65 | }
66 | },
67 |
68 | render() {
69 | //console.log("AsyncSelectInput.render() - value=", this.props.value)
70 |
71 | // This is for bootstrap, we want to wrap our label and textbox in a 'form-group'
72 | // class, and also to add 'has-error' (which gives us a red outline) if the data is in error
73 | var wrapperClass = 'form-group';
74 | if (this.props.error && this.props.error.length > 0) {
75 | wrapperClass += " " + 'has-error';
76 | }
77 |
78 | const humanizedName = humanize(this.props.name);
79 |
80 | return (
81 |
82 | {this.renderLabel()}
83 |
84 |
99 |
100 | {this.props.error}
101 |
102 |
103 |
104 |
105 | );
106 | }
107 | });
108 |
109 | export default AsyncSelectInput;
110 |
111 | //getOptions(input, callback) {
112 | // input = input.toLowerCase();
113 | //
114 | // var data = {
115 | // options: [
116 | // { _id: '1', name: 'Hard' },
117 | // { _id: '2', name: 'Hord' },
118 | // { _id: '3', name: 'Harris' },
119 | // { _id: '4', name: 'Ham' },
120 | // { _id: '5', name: 'Hockney' },
121 | // { _id: '6', name: 'Horris' },
122 | // { _id: '7', name: 'Hamilton' },
123 | // { _id: '8', name: 'Honest' }
124 | // ],
125 | // // CAREFUL! Only set this to true when there are no more options,
126 | // // or more specific queries will not be sent to the server.
127 | // complete: true
128 | // };
129 | //
130 | // setTimeout(function () {
131 | // callback(null, data);
132 | // }, 500);
133 | //},
134 |
135 |
--------------------------------------------------------------------------------
/imports/api/orders/order.js:
--------------------------------------------------------------------------------
1 | import { recalculateOrderTotals } from '../../../lib/order-logic';
2 | import CustomerCompanies from '../customers/customer-company';
3 | import { createCollection } from '../lib/collection-helpers.js';
4 |
5 |
6 | // Make it available to the rest of the app
7 | const Orders = createCollection("Orders", Schemas.OrderSchema);
8 |
9 | Orders.before.insert(function (userId, doc) {
10 | //console.log("Orders.before.insert", doc);
11 | customerCompanyDenormalizer.beforeInsert(userId, doc);
12 | });
13 |
14 | Orders.before.update(function (userId, doc, fieldNames, modifier, options) {
15 | //.log("Orders.before.update", doc);
16 | customerCompanyDenormalizer.beforeUpdate(userId, doc, fieldNames, modifier, options);
17 | });
18 |
19 | Orders.before.upsert(function (userId, selector, modifier, options) {
20 | //console.log("Orders.before.upsert", modifier.$set);
21 | customerCompanyDenormalizer.beforeUpsert(userId, selector, modifier, options);
22 | });
23 |
24 |
25 | Orders.after.insert(function (userId, doc) {
26 | //console.log("Orders.after.insert", doc);
27 | customerCompanyDenormalizer.afterInsert(userId, doc);
28 | });
29 |
30 | Orders.after.update(function (userId, doc, fieldNames, modifier, options) {
31 | //console.log("Orders.after.update", doc);
32 | customerCompanyDenormalizer.afterUpdate(userId, doc, fieldNames, modifier, options, this.previous);
33 | });
34 |
35 |
36 | const customerCompanyDenormalizer = {
37 |
38 | // Ensure that no matter what customerName we receive from the client,
39 | // the correct name for the selected customerId is always set.
40 | _updateCompanyNameOnOrder(order) {
41 |
42 | // We only want to do this update on the server - it was already done on the client
43 | // And wouldn't work on the client as we are accessing the customer table directly
44 | // and also because the client miniMongo data subset may not contain the customer
45 | // at that point in time
46 | if (Meteor.isServer) {
47 | //console.log("customerCompanyDenormalizer._updateCompanyNameOnOrder() ",
48 | // order.customerId + " - " + order.customerName);
49 |
50 | // no action needed if the customerId is not set
51 | if (!order.customerId || order.customerId === null) {
52 | return;
53 | }
54 |
55 | //const handle = Meteor.subscribe('CustomerCompany.get', order.customerId);
56 | const customer = CustomerCompanies.findOne({_id: order.customerId});
57 |
58 | if (!customer) {
59 | throw new Meteor.Error("The customer could not be found in the database");
60 | }
61 |
62 | order.customerName = customer.name;
63 | }
64 | },
65 |
66 | _updateCompanyOrderTotals(customerId, previousCustomerId) {
67 | if (Meteor.isServer) {
68 | //console.log("_updateCompanyOrderTotals", customerId);
69 |
70 | // no action needed if the customerId is not set
71 | if (!customerId || customerId === null) {
72 | return;
73 | }
74 |
75 | let customerIds = [ customerId ];
76 |
77 | // if the customer Id changed we also need to update the order totals for
78 | // the old customer
79 | if (customerId !== previousCustomerId) {
80 | customerIds.push(previousCustomerId);
81 | }
82 |
83 | customerIds.forEach(function (thisCustomerId) {
84 | let pipeline = [
85 | {
86 | $match: {
87 | customerId: thisCustomerId
88 | }
89 | },
90 | {
91 | $group: {
92 | _id: null,
93 | ordersTotalValue: {$sum: "$totalValue"},
94 | ordersCount: {$sum: 1}
95 | }
96 | }
97 | ];
98 |
99 | //console.log("thisCustomerId: ", thisCustomerId);
100 | let result = Orders.aggregate(pipeline, {customerId: thisCustomerId})[0];
101 |
102 | //console.log("result: ", result);
103 | CustomerCompanies.update(thisCustomerId, {
104 | $set: {
105 | // the result will be null if this customer now has no orders
106 | ordersTotalValue: result ? result.ordersTotalValue : 0,
107 | ordersCount: result ? result.ordersCount : 0
108 | //email: "hi@hi.com" //+ new Date().toTimeString()
109 | }
110 | });
111 | });
112 |
113 | //console.log("_updateCompanyOrderTotals Completed");
114 | }
115 | },
116 |
117 | _performCommonBeforeModifyActions(orderDoc) {
118 | recalculateOrderTotals(orderDoc);
119 | this._updateCompanyNameOnOrder(orderDoc);
120 | },
121 |
122 | _performCommonAfterModifyActions(orderDoc, previousDoc) {
123 | // Previous doc will be null for new records.
124 | this._updateCompanyOrderTotals(orderDoc.customerId, previousDoc ? previousDoc.customerId : null);
125 | },
126 |
127 | beforeInsert(userId, doc) {
128 | this._performCommonBeforeModifyActions(doc);
129 | },
130 |
131 | beforeUpdate(userId, doc, fieldNames, modifier, options) {
132 | this._performCommonBeforeModifyActions(doc);
133 | },
134 |
135 | beforeUpsert(userId, selector, modifier, options) {
136 | this._performCommonBeforeModifyActions(modifier.$set);
137 | },
138 |
139 | afterInsert(userId, doc) {
140 | this._performCommonAfterModifyActions(doc);
141 | },
142 |
143 | afterUpdate(userId, doc, fieldNames, modifier, options, previousDoc) {
144 | //console.log("previousDoc: ", previousDoc);
145 | this._performCommonAfterModifyActions(doc, previousDoc);
146 | }
147 |
148 | };
149 |
150 | export default Orders;
151 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "es6": true,
4 | "browser": true,
5 | "node": true
6 | },
7 |
8 | "plugins": [
9 | "react"
10 | ],
11 |
12 | "ecmaFeatures": {
13 | "arrowFunctions": true,
14 | "binaryLiterals": true,
15 | "blockBindings": true,
16 | "classes": true,
17 | "defaultParams": true,
18 | "destructuring": true,
19 | "experimentalObjectRestSpread": true,
20 | "forOf": true,
21 | "generators": true,
22 | "globalReturn": true,
23 | "jsx": true,
24 | "modules": true,
25 | "objectLiteralComputedProperties": true,
26 | "objectLiteralDuplicateProperties": true,
27 | "objectLiteralShorthandMethods": true,
28 | "objectLiteralShorthandProperties": true,
29 | "octalLiterals": true,
30 | "regexUFlag": true,
31 | "regexYFlag": true,
32 | "restParams": true,
33 | "spread": true,
34 | "superInFunctions": true,
35 | "templateStrings": true,
36 | "unicodeCodePointEscapes": true
37 | },
38 |
39 | "rules": {
40 | "array-bracket-spacing": [2, "always"],
41 | "arrow-spacing": 2,
42 | "block-scoped-var": 0,
43 | "brace-style": [2, "1tbs", {"allowSingleLine": true}],
44 | "callback-return": 2,
45 | "camelcase": [2, {"properties": "always"}],
46 | "comma-dangle": 0,
47 | "comma-spacing": 0,
48 | "comma-style": [2, "last"],
49 | "complexity": 0,
50 | "computed-property-spacing": [2, "never"],
51 | "consistent-return": 0,
52 | "consistent-this": 0,
53 | "curly": [2, "all"],
54 | "default-case": 0,
55 | "dot-location": [2, "property"],
56 | "dot-notation": 0,
57 | "eol-last": 0,
58 | "eqeqeq": 2,
59 | "func-names": 0,
60 | "func-style": 0,
61 | "generator-star-spacing": [0, {"before": true, "after": false}],
62 | "guard-for-in": 2,
63 | "handle-callback-err": [2, "error"],
64 | "id-length": 0,
65 | "id-match": [2, "^(?:_?[a-zA-Z0-9]*)|[_A-Z0-9]+$"],
66 |
67 | "init-declarations": 0,
68 | "key-spacing": [2, {"beforeColon": false, "afterColon": true}],
69 | "linebreak-style": 0,
70 | "lines-around-comment": 0,
71 | "max-depth": 0,
72 | "max-len": [2, 130, 4],
73 | "max-nested-callbacks": 0,
74 | "max-params": 0,
75 | "max-statements": 0,
76 | "new-cap": 0,
77 | "new-parens": 2,
78 | "newline-after-var": 0,
79 | "no-array-constructor": 2,
80 | "no-bitwise": 0,
81 | "no-caller": 2,
82 | "no-catch-shadow": 0,
83 | "no-class-assign": 2,
84 | "no-cond-assign": 2,
85 | "no-console": 0,
86 | "no-const-assign": 2,
87 | "no-constant-condition": 2,
88 | "no-continue": 0,
89 | "no-control-regex": 0,
90 | "no-debugger": 1,
91 | "no-delete-var": 2,
92 | "no-div-regex": 2,
93 | "no-dupe-args": 2,
94 | "no-dupe-keys": 2,
95 | "no-duplicate-case": 2,
96 | "no-else-return": 2,
97 | "no-empty": 2,
98 | "no-empty-character-class": 2,
99 | "no-empty-label": 2,
100 | "no-eq-null": 0,
101 | "no-eval": 2,
102 | "no-ex-assign": 2,
103 | "no-extend-native": 2,
104 | "no-extra-bind": 2,
105 | "no-extra-boolean-cast": 2,
106 | "no-extra-parens": 0,
107 | "no-extra-semi": 2,
108 | "no-fallthrough": 2,
109 | "no-floating-decimal": 2,
110 | "no-func-assign": 2,
111 | "no-implicit-coercion": 2,
112 | "no-implied-eval": 2,
113 | "no-inline-comments": 0,
114 | "no-inner-declarations": [2, "functions"],
115 | "no-invalid-regexp": 2,
116 | "no-invalid-this": 0,
117 | "no-irregular-whitespace": 2,
118 | "no-iterator": 2,
119 | "no-label-var": 2,
120 | "no-labels": 0,
121 | "no-lone-blocks": 2,
122 | "no-lonely-if": 2,
123 | "no-loop-func": 0,
124 | "no-mixed-requires": [2, true],
125 | "no-mixed-spaces-and-tabs": 2,
126 | "no-multi-spaces": 2,
127 | "no-multi-str": 2,
128 | "no-multiple-empty-lines": 0,
129 | "no-native-reassign": 0,
130 | "no-negated-in-lhs": 2,
131 | "no-nested-ternary": 0,
132 | "no-new": 2,
133 | "no-new-func": 0,
134 | "no-new-object": 2,
135 | "no-new-require": 2,
136 | "no-new-wrappers": 2,
137 | "no-obj-calls": 2,
138 | "no-octal": 2,
139 | "no-octal-escape": 2,
140 | "no-param-reassign": 2,
141 | "no-path-concat": 2,
142 | "no-plusplus": 0,
143 | "no-process-env": 0,
144 | "no-process-exit": 0,
145 | "no-proto": 2,
146 | "no-redeclare": 2,
147 | "no-regex-spaces": 2,
148 | "no-restricted-modules": 0,
149 | "no-return-assign": 2,
150 | "no-script-url": 2,
151 | "no-self-compare": 0,
152 | "no-sequences": 2,
153 | "no-shadow": 2,
154 | "no-shadow-restricted-names": 2,
155 | "no-spaced-func": 2,
156 | "no-sparse-arrays": 2,
157 | "no-sync": 2,
158 | "no-ternary": 0,
159 | "no-this-before-super": 2,
160 | "no-throw-literal": 2,
161 | "no-undef": 0,
162 | "no-undef-init": 2,
163 | "no-undefined": 0,
164 | "no-underscore-dangle": 0,
165 | "no-unexpected-multiline": 2,
166 | "no-unneeded-ternary": 2,
167 | "no-unreachable": 2,
168 | "no-unused-expressions": 2,
169 | "no-unused-vars": [1, {"vars": "all", "args": "after-used"}],
170 | "no-use-before-define": 0,
171 | "no-useless-call": 2,
172 | "no-var": 0,
173 | "no-void": 2,
174 | "no-warning-comments": 0,
175 | "no-with": 2,
176 | "object-curly-spacing": [0, "always"],
177 | "object-shorthand": [2, "always"],
178 | "one-var": [2, "never"],
179 | "operator-assignment": [2, "always"],
180 | "operator-linebreak": [2, "after"],
181 | "padded-blocks": 0,
182 | "prefer-const": 0,
183 | "prefer-reflect": 0,
184 | "prefer-spread": 0,
185 | "quote-props": [2, "as-needed"],
186 | "radix": 2,
187 | "require-yield": 2,
188 | "semi": [2, "always"],
189 | "semi-spacing": [2, {"before": false, "after": true}],
190 | "sort-vars": 0,
191 | "space-after-keywords": [2, "always"],
192 | "space-before-blocks": [2, "always"],
193 | "space-before-function-paren": [2, {"anonymous": "always", "named": "never"}],
194 | "space-in-parens": 0,
195 | "space-infix-ops": [2, {"int32Hint": false}],
196 | "space-return-throw-case": 2,
197 | "space-unary-ops": [2, {"words": true, "nonwords": false}],
198 | "strict": 0,
199 | "use-isnan": 2,
200 | "valid-jsdoc": 0,
201 | "valid-typeof": 2,
202 | "vars-on-top": 0,
203 | "wrap-iife": 2,
204 | "wrap-regex": 0,
205 | "yoda": [2, "never", {"exceptRange": true}],
206 | "react/jsx-uses-react": 1
207 | }
208 | }
--------------------------------------------------------------------------------
/imports/ui/products/products-list.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import accounting from 'accounting';
3 | import Alert from 'react-s-alert';
4 |
5 | import GridRow from '../components/grid/GridRow.jsx'
6 | import GridColumn from '../components/grid/GridColumn.jsx'
7 | import GridHeaderColumn from '../components/grid/GridHeaderColumn.jsx'
8 | import GridHeaderRow from '../components/grid/GridHeaderRow.jsx'
9 |
10 | import { upsert, remove } from '../../api/products/methods';
11 |
12 | // This click to edit grid comes from this example on Meteor Chef:
13 | // https://themeteorchef.com/snippets/click-to-edit-fields-in-react/
14 | const ProductsList = React.createClass({
15 | propTypes: {
16 | items: React.PropTypes.array.isRequired
17 | },
18 |
19 | getInitialState() {
20 | return {
21 | editing: null
22 | };
23 | },
24 |
25 | handleItemUpdate(update) {
26 |
27 | const args = {
28 | productId: update._id,
29 | data: {
30 | name: update.name,
31 | price: update.price,
32 | createdAt: update.createdAt
33 | }
34 | };
35 |
36 | upsert.call(
37 | args
38 | , (error, response) => {
39 | if (error) {
40 | console.log(error.reason);
41 | Alert.error(error.reason);
42 | } else {
43 | this.setState({editing: null});
44 | Alert.success('Product updated successfully');
45 | }
46 | });
47 | },
48 |
49 | handleEditField(event) {
50 | if (event.keyCode === 13) {
51 | let target = event.target;
52 | let update = {};
53 |
54 | update._id = this.state.editing;
55 | update[target.name] = target.value;
56 |
57 | this.handleItemUpdate(update);
58 | }
59 | },
60 | handleEditItem() {
61 | let itemId = this.state.editing;
62 |
63 | this.handleItemUpdate({
64 | _id: itemId,
65 | name: this.refs[`name_${ itemId }`].value,
66 | price: this.refs[`price_${ itemId }`].value,
67 | createdAt: this.refs[`createdAt_${ itemId }`].value
68 | });
69 | },
70 |
71 | toggleEditing(itemId) {
72 | this.setState({editing: itemId});
73 | },
74 |
75 | renderItemOrEditField(item) {
76 | if (this.state.editing === item._id) {
77 | return
78 |
79 |
80 |
88 |
89 |
90 |
98 |
99 |
100 |
109 |
110 |
111 | Update Item
112 |
113 |
114 | ;
115 | } else {
116 | return
118 |
119 |
120 | { item.name}
121 |
122 |
123 | { accounting.formatMoney(item.price, "£") }
124 |
125 |
126 | { item.createdAt.toLocaleDateString() }
127 |
128 |
129 | Edit Item
130 |
131 |
132 | ;
133 | }
134 | },
135 |
136 | render() {
137 | //console.log("render()", this.props);
138 |
139 | return (
140 |
141 |
Products
142 |
143 |
144 |
145 |
146 | Name
147 |
148 |
149 | Price
150 |
151 |
152 | Created
153 |
154 |
155 |
156 |
157 |
158 | {this.props.items.map((item) => {
159 | return this.renderItemOrEditField(item);
160 | })}
161 |
162 |
163 | );
164 | }
165 | });
166 |
167 | export default ProductsList;
168 |
--------------------------------------------------------------------------------
/imports/ui/search/GlobalSearch.jsx:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | import Select from 'react-select';
3 | import accounting from 'accounting';
4 | import Alert from 'react-s-alert';
5 |
6 | import Products from '../../api/products/products';
7 | import Orders from '../../api/orders/order';
8 |
9 | const GlobalSearch = React.createClass({
10 |
11 | getInitialState() {
12 | //console.log("Empty.getInitialState(): props", this.props);
13 |
14 | return {
15 | searchTerm: {
16 | _id: '',
17 | name: ''
18 | },
19 | error: ""
20 | };
21 | },
22 |
23 | onChange(selectedItem) {
24 | //return this.setState({searchTerm: });
25 | },
26 |
27 | getResults(input, callback) {
28 | //console.log("getResults input:", input);
29 |
30 | let products = [];
31 | let orders = [];
32 | let customerCompanies = [];
33 |
34 | let results = [];
35 |
36 | // Get the Products
37 | Meteor.call('Products.fullTextSearch.method', {
38 | searchValue: input
39 | }, (err, res) => {
40 | if (err) {
41 | Alert("Could not retrieve Product search matches");
42 | console.log('Products.fullTextSearch.method Error: ', err);
43 | } else {
44 | //console.log("Products res", res);
45 | products = res.map(function (product) {
46 | return {
47 | _id: product._id,
48 | name: product.name,
49 | isProduct: true
50 | };
51 | });
52 |
53 | // Get the Orders
54 | Meteor.call('Orders.fullTextSearch.method', {
55 | searchValue: input
56 | }, (err1, res1) => {
57 | if (err1) {
58 | Alert("Could not retrieve Order search matches");
59 | console.log('Orders.fullTextSearch.method Error: ', err1);
60 | } else {
61 | //console.log("Orders res", res1);
62 | orders = res1.map(function (order) {
63 | return {
64 | _id: order._id,
65 | name: order.createdAt.toLocaleDateString() + " - " + order.customerName,
66 | isOrder: true
67 | };
68 | });
69 |
70 | // Get the CustomerCompanies
71 | Meteor.call('CustomerCompanies.fullTextSearch.method', {
72 | searchValue: input
73 | }, (err2, res2) => {
74 | if (err2) {
75 | Alert("Could not retrieve Customer search matches");
76 | console.log('CustomerCompanies.fullTextSearch.method Error: ', err2);
77 | } else {
78 | //console.log("CustomerCompanies res", res2);
79 | customerCompanies = res2.map(function (customer) {
80 | return {
81 | _id: customer._id,
82 | name: customer.name,
83 | isCustomer: true
84 | };
85 | });
86 |
87 | // Concatenate the whole lot into a single list with some headings
88 | let options = [].concat(
89 | products.length > 0 ? [ {_id: '', name: "Products:", heading: true, disabled: true} ] : [],
90 | products,
91 | orders.length > 0 ? [ {_id: '', name: "Orders:", heading: true, disabled: true } ] : [],
92 | orders,
93 | customerCompanies.length > 0 ? [ {
94 | _id: '', name: "Customers:", heading: true, disabled: true
95 | } ] : [],
96 | customerCompanies
97 | );
98 |
99 | //console.log("options: ", options);
100 |
101 |
102 | var data = {
103 | options,
104 | complete: false
105 | };
106 | //console.log("data", data.options);
107 |
108 | callback(null, data);
109 | }
110 | });
111 |
112 | }
113 | });
114 |
115 | }
116 | });
117 | },
118 |
119 | renderOption(option) {
120 | if (option.heading) {
121 | return {option.name} ;
122 | } else if (option.isCustomer) {
123 | return {option.name} ;
124 | } else if (option.isProduct) {
125 | return {option.name} ;
126 | } else if (option.isOrder) {
127 | return {option.name} ;
128 | }
129 |
130 | // unexpected option
131 | return {option.name} ;
132 | },
133 |
134 | render() {
135 | //console.log("render()", this.props);
136 |
137 | return (
138 |
139 |
156 | );
157 | }
158 | });
159 |
160 | export default GlobalSearch;
161 |
--------------------------------------------------------------------------------
/imports/ui/redux/order-actions.jsx:
--------------------------------------------------------------------------------
1 | import { trackCollection } from 'meteor/skinnygeek1010:flux-helpers';
2 | import Alert from 'react-s-alert';
3 |
4 | import { validateItemAndAddValidationResults, validateItemAgainstSchema } from '../../../lib/validation-helpers';
5 | import { recalculateOrderTotals } from '../../../lib/order-logic';
6 |
7 | import Orders from '../../api/orders/order';
8 | import { upsert, remove } from '../../api/orders/methods';
9 | import store from './store.jsx';
10 |
11 |
12 | // Add a listener that will trigger a render when the collection changes
13 | Meteor.startup(function () { // work around files not being defined yet
14 | //console.log("Orders collection, add Redux tracking");
15 |
16 | if (Meteor.isClient) { // work around not having actions in /both folder
17 | // trigger action when this changes
18 | trackCollection(Orders, (data) => {
19 | store.dispatch(ordersCollectionChanged(data));
20 | });
21 | }
22 | });
23 |
24 |
25 | // used when a mongo orders collection changes
26 | function ordersCollectionChanged(newDocs) {
27 | //console.log("OrderActions.ordersCollectionChanged ", newDocs);
28 |
29 | return (dispatch, getState) => {
30 | //console.log("inner OrderActions.ordersCollectionChanged ");
31 |
32 | return {
33 | type: 'ORDERS_COLLECTION_CHANGED',
34 | collection: newDocs
35 | };
36 | }
37 | }
38 |
39 |
40 | // doesn't return payload because our collection watcher
41 | // will send a CHANGED action and update the store
42 | export function saveOrder(order) {
43 | //console.log("saveOrder: ", order);
44 |
45 | return (dispatch, getState) => {
46 |
47 | // call the method for upserting the data
48 | upsert.call({
49 | orderId: order._id,
50 | data: order
51 | }, (err, res) => {
52 | //console.log ("Orders.methods.updateManualForm.call was called");
53 | if (err) {
54 | // TODO call FAILED action on error
55 | Alert.error(err.message);
56 | } else {
57 | Alert.success("Save successful");
58 | FlowRouter.go("/");
59 | dispatch ({
60 | type: 'SAVE_ORDER'
61 | });
62 | }
63 | });
64 | }
65 | }
66 |
67 | export const EDIT_ORDER = 'EDIT_ORDER';
68 | function sendOrderChanges(order) {
69 | return {
70 | type: EDIT_ORDER,
71 | order
72 | }
73 | }
74 |
75 | export function editOrder(order, newValues) {
76 | //console.log("OrderActions.editOrder() event.target:" + newValues);
77 |
78 | return (dispatch, getState) => {
79 |
80 | // don't mutate it
81 | const order = _.clone(getState().orderBeingEdited.order);
82 |
83 | // loop each change and apply to our clone
84 | for (let newValue of newValues) {
85 | order[newValue.name] = newValue.value;
86 | }
87 |
88 | // validate and set error messages
89 | validateItemAndAddValidationResults(order, Schemas.OrderSchema);
90 |
91 | //console.log("inner OrderActions.editOrder() " );
92 | dispatch (sendOrderChanges(order));
93 | }
94 | }
95 |
96 |
97 | export function editOrderLine(orderLineId, field, value) {
98 | //console.log("OrderActions.editOrder() event.value:" + value);
99 |
100 | return (dispatch, getState) => {
101 |
102 | // get the order and line - don't mutate
103 | const order = _.clone(getState().orderBeingEdited.order);
104 | const line = order.orderLines.find(x => x._id === orderLineId);
105 |
106 | line[field] = value;
107 |
108 | // validate and set error messages
109 | recalculateOrderTotals(order);
110 |
111 | validateItemAndAddValidationResults(line, Schemas.OrderLineSchema);
112 |
113 | //console.log("inner OrderActions.editOrderLine()", order);
114 | dispatch (sendOrderChanges(order));
115 | }
116 | }
117 |
118 |
119 | export function editOrderLineProduct(orderLineId, newValue) {
120 | //console.log("OrderActions.editOrderLineProduct() newValue:" + newValue);
121 |
122 | return (dispatch, getState) => {
123 |
124 | // get the order and line - don't mutate
125 | const order = _.clone(getState().orderBeingEdited.order);
126 | const line = order.orderLines.find(x => x._id === orderLineId);
127 |
128 | line.productId = newValue.selectedOption._id;
129 | line.description = newValue.selectedOption.name;
130 | line.unitPrice = newValue.selectedOption.price;
131 |
132 | // validate and set error messages
133 | recalculateOrderTotals(order);
134 |
135 | validateItemAndAddValidationResults(line, Schemas.OrderLineSchema);
136 |
137 | //console.log("inner OrderActions.editOrderLineProduct()", order);
138 | dispatch (sendOrderChanges(order));
139 | }
140 | }
141 |
142 |
143 | export function addNewOrderLine(event) {
144 | return (dispatch, getState) => {
145 | //console.log("addNewOrderLine");
146 |
147 | event.preventDefault();
148 |
149 | // get the order and line - don't mutate
150 | const order = _.clone(getState().orderBeingEdited.order);
151 |
152 | order.orderLines.push(getEmptyOrderLine());
153 |
154 | dispatch (sendOrderChanges(order));
155 | }
156 | }
157 |
158 |
159 | export function deleteOrderLine(id) {
160 | return (dispatch, getState) => {
161 | //console.log("inner deleteOrderLine");
162 |
163 | event.preventDefault();
164 |
165 | // get the order and line - don't mutate
166 | const order = _.clone(getState().orderBeingEdited.order);
167 |
168 | const line = order.orderLines.find(x => x._id === id);
169 | const pos = order.orderLines.indexOf(line);
170 |
171 | order.orderLines.splice(pos, 1);
172 |
173 | // update the calculated totals
174 | recalculateOrderTotals(order);
175 |
176 | dispatch (sendOrderChanges(order));
177 | }
178 | }
179 | function getEmptyOrderLine() {
180 | return {
181 | _id: Meteor.uuid(),
182 | productId: null,
183 | description: null,
184 | quantity: 0,
185 | unitPrice: 0,
186 | lineValue: 0,
187 | createdAt: new Date(),
188 | errors: {}
189 | };
190 | }
191 |
192 | export function selectOrder(orderId) {
193 | //console.log("OrderActions.selectOrder: " + orderId.toString());
194 | return (dispatch, getState) => {
195 | //console.log("INNER Actions.selectOrder: " + orderId.toString());
196 |
197 | const order = Orders.findOne({_id: orderId});
198 |
199 | // perform initial validation and set error messages
200 | validateItemAndAddValidationResults(order, Schemas.OrderSchema);
201 |
202 | order.orderLines.forEach(line => {
203 | validateItemAndAddValidationResults(line, Schemas.OrderLineSchema);
204 | });
205 |
206 | dispatch ({
207 | type: 'SELECT_ORDER',
208 | order
209 | });
210 | }
211 | }
212 |
213 | export function selectNewOrder() {
214 | //console.log("OrderActions.selectNewOrder ")
215 |
216 | return (dispatch, getState) => {
217 |
218 | const order = {
219 | orderLines: [],
220 | createdAt: new Date(),
221 | errors: {}
222 | };
223 |
224 | dispatch ({
225 | type: 'SELECT_ORDER',
226 | order
227 | });
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/imports/ui/stylesheets/sbAdmin2/sb-admin-2.less:
--------------------------------------------------------------------------------
1 | @import "variables.less";
2 | @import "mixins.less";
3 |
4 | // Global Styles
5 |
6 | body {
7 | background-color: @gray-lightest;
8 | }
9 |
10 | // Wrappers
11 |
12 | #wrapper {
13 | width: 100%;
14 | }
15 |
16 | #page-wrapper {
17 | padding: 0 15px;
18 | min-height: 568px;
19 | //background-color: greenyellow;
20 | }
21 |
22 | @media(min-width:768px) {
23 | #page-wrapper {
24 | position: inherit;
25 | margin: 0 0 0 250px;
26 | padding: 100px 30px;
27 | border-left: 1px solid darken(@gray-lightest, 6.5%);
28 | }
29 | }
30 |
31 |
32 | @media(max-width:768px) {
33 | #page-wrapper {
34 | padding: 110px 30px;
35 | }
36 | }
37 |
38 |
39 | // Navigation
40 |
41 | // --Topbar
42 |
43 | .navbar-top-links {
44 | margin-right: 0;
45 | }
46 |
47 | .navbar-top-links li {
48 | display: inline-block;
49 | }
50 |
51 | .navbar-top-links li:last-child {
52 | margin-right: 15px;
53 | }
54 |
55 | .navbar-top-links li a {
56 | padding: 15px;
57 | min-height: 50px;
58 | }
59 |
60 | .navbar-top-links .dropdown-menu li {
61 | display: block;
62 | }
63 |
64 | .navbar-top-links .dropdown-menu li:last-child {
65 | margin-right: 0;
66 | }
67 |
68 | .navbar-top-links .dropdown-menu li a {
69 | padding: 3px 20px;
70 | min-height: 0;
71 | }
72 |
73 | .navbar-top-links .dropdown-menu li a div {
74 | white-space: normal;
75 | }
76 |
77 | .navbar-top-links .dropdown-messages,
78 | .navbar-top-links .dropdown-tasks,
79 | .navbar-top-links .dropdown-alerts {
80 | width: 310px;
81 | min-width: 0;
82 | }
83 |
84 | .navbar-top-links .dropdown-messages {
85 | margin-left: 5px;
86 | }
87 |
88 | .navbar-top-links .dropdown-tasks {
89 | margin-left: -59px;
90 | }
91 |
92 | .navbar-top-links .dropdown-alerts {
93 | margin-left: -123px;
94 | }
95 |
96 | .navbar-top-links .dropdown-user {
97 | right: 0;
98 | left: auto;
99 | }
100 |
101 | .navbar-right {
102 | margin-right:0;
103 | }
104 | // --Sidebar
105 |
106 | .sidebar {
107 | .sidebar-nav.navbar-collapse {
108 | padding-left: 0;
109 | padding-right: 0;
110 | }
111 | }
112 |
113 | .sidebar .sidebar-search {
114 | padding: 15px;
115 | }
116 |
117 | .sidebar ul li {
118 | border-bottom: 1px solid darken(@gray-lightest, 6.5%);
119 | a {
120 | &.active {
121 | background-color: @gray-lighter;
122 | }
123 | }
124 | }
125 |
126 | .sidebar .arrow {
127 | float: right;
128 | }
129 |
130 | .sidebar .fa.arrow:before {
131 | content: "\f104";
132 | }
133 |
134 | .sidebar .active > a > .fa.arrow:before {
135 | content: "\f107";
136 | }
137 |
138 | .sidebar .nav-second-level li,
139 | .sidebar .nav-third-level li {
140 | border-bottom: none !important;
141 | }
142 |
143 | .sidebar .nav-second-level li a {
144 | padding-left: 37px;
145 | }
146 |
147 | .sidebar .nav-third-level li a {
148 | padding-left: 52px;
149 | }
150 |
151 | @media(min-width:768px) {
152 | .sidebar {
153 | z-index: 1;
154 | position: absolute;
155 | width: 250px;
156 | margin-top: 71px;
157 | }
158 |
159 | .navbar-top-links .dropdown-messages,
160 | .navbar-top-links .dropdown-tasks,
161 | .navbar-top-links .dropdown-alerts {
162 | margin-left: auto;
163 | }
164 | }
165 |
166 | // Buttons
167 |
168 | .btn-outline {
169 | color: inherit;
170 | background-color: transparent;
171 | transition: all .5s;
172 | }
173 |
174 | .btn-primary.btn-outline {
175 | color: @brand-primary;
176 | }
177 |
178 | .btn-success.btn-outline {
179 | color: @brand-success;
180 | }
181 |
182 | .btn-info.btn-outline {
183 | color: @brand-info;
184 | }
185 |
186 | .btn-warning.btn-outline {
187 | color: @brand-warning;
188 | }
189 |
190 | .btn-danger.btn-outline {
191 | color: @brand-danger;
192 | }
193 |
194 | .btn-primary.btn-outline:hover,
195 | .btn-success.btn-outline:hover,
196 | .btn-info.btn-outline:hover,
197 | .btn-warning.btn-outline:hover,
198 | .btn-danger.btn-outline:hover {
199 | color: white;
200 | }
201 |
202 | // Chat Widget
203 |
204 | .chat {
205 | margin: 0;
206 | padding: 0;
207 | list-style: none;
208 | }
209 |
210 | .chat li {
211 | margin-bottom: 10px;
212 | padding-bottom: 5px;
213 | border-bottom: 1px dotted @gray-light;
214 | }
215 |
216 | .chat li.left .chat-body {
217 | margin-left: 60px;
218 | }
219 |
220 | .chat li.right .chat-body {
221 | margin-right: 60px;
222 | }
223 |
224 | .chat li .chat-body p {
225 | margin: 0;
226 | }
227 |
228 | .panel .slidedown .glyphicon,
229 | .chat .glyphicon {
230 | margin-right: 5px;
231 | }
232 |
233 | .chat-panel .panel-body {
234 | height: 350px;
235 | overflow-y: scroll;
236 | }
237 |
238 | // Login Page
239 |
240 | .login-panel {
241 | margin-top: 25%;
242 | }
243 |
244 | //// Flot Charts Containers
245 | //
246 | //.flot-chart {
247 | // display: block;
248 | // height: 400px;
249 | //}
250 | //
251 | //.flot-chart-content {
252 | // width: 100%;
253 | // height: 100%;
254 | //}
255 | //
256 | //// DataTables Overrides
257 | //
258 | //table.dataTable thead .sorting,
259 | //table.dataTable thead .sorting_asc,
260 | //table.dataTable thead .sorting_desc,
261 | //table.dataTable thead .sorting_asc_disabled,
262 | //table.dataTable thead .sorting_desc_disabled {
263 | // background: transparent;
264 | //}
265 | //
266 | //table.dataTable thead .sorting_asc:after {
267 | // content: "\f0de";
268 | // float: right;
269 | // font-family: fontawesome;
270 | //}
271 | //
272 | //table.dataTable thead .sorting_desc:after {
273 | // content: "\f0dd";
274 | // float: right;
275 | // font-family: fontawesome;
276 | //}
277 | //
278 | //table.dataTable thead .sorting:after {
279 | // content: "\f0dc";
280 | // float: right;
281 | // font-family: fontawesome;
282 | // color: rgba(50,50,50,.5);
283 | //}
284 |
285 | // Circle Buttons
286 |
287 | .btn-circle {
288 | width: 30px;
289 | height: 30px;
290 | padding: 6px 0;
291 | border-radius: 15px;
292 | text-align: center;
293 | font-size: 12px;
294 | line-height: 1.428571429;
295 | }
296 |
297 | .btn-circle.btn-lg {
298 | width: 50px;
299 | height: 50px;
300 | padding: 10px 16px;
301 | border-radius: 25px;
302 | font-size: 18px;
303 | line-height: 1.33;
304 | }
305 |
306 | .btn-circle.btn-xl {
307 | width: 70px;
308 | height: 70px;
309 | padding: 10px 16px;
310 | border-radius: 35px;
311 | font-size: 24px;
312 | line-height: 1.33;
313 | }
314 |
315 | // Grid Demo Elements
316 |
317 | .show-grid [class^="col-"] {
318 | padding-top: 10px;
319 | padding-bottom: 10px;
320 | border: 1px solid #ddd;
321 | background-color: #eee !important;
322 | }
323 |
324 | .show-grid {
325 | margin: 15px 0;
326 | }
327 |
328 | // Custom Colored Panels
329 |
330 | .huge {
331 | font-size: 40px;
332 | }
333 |
334 | .panel-green {
335 | border-color: @brand-success;
336 | .panel-heading {
337 | border-color: @brand-success;
338 | color: white;
339 | background-color: @brand-success;
340 | }
341 | a {
342 | color: @brand-success;
343 | &:hover {
344 | color: darken(@brand-success, 15%);
345 | }
346 | }
347 | }
348 |
349 | .panel-red {
350 | border-color: @brand-danger;
351 | .panel-heading {
352 | border-color: @brand-danger;
353 | color: white;
354 | background-color: @brand-danger;
355 | }
356 | a {
357 | color: @brand-danger;
358 | &:hover {
359 | color: darken(@brand-danger, 15%);
360 | }
361 | }
362 | }
363 |
364 | .panel-yellow {
365 | border-color: @brand-warning;
366 | .panel-heading {
367 | border-color: @brand-warning;
368 | color: white;
369 | background-color: @brand-warning;
370 | }
371 | a {
372 | color: @brand-warning;
373 | &:hover {
374 | color: darken(@brand-warning, 15%);
375 | }
376 | }
377 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## What is Simple CRM?
2 |
3 | I come from a .Net background and Simple CRM is a SPA website I built in order to teach myself JavaScript, Meteor, React, Redux, Git etc. I've been working on it fairly full time for around 6 weeks, but beware that I only started learning JavaScript about three months ago.
4 |
5 | I hope that, after I incorporate feedback from more experienced developers, Simple CRM can become a valuable resource to the community that provides the next step in learning after the Todo app examples. That was a resource that I was desperately looking for but never found when I was learning.
6 |
7 | Sorry to say the online demo has now gone as Meteor.com free hosting is no longer available and I haven't had time to investigate alternate free hosting.
8 |
9 | ## What does the app do?
10 |
11 | 
12 |
13 | The code tries to provide examples of all of the common stuff you need to write for line of business and data-centric apps.
14 |
15 | Namely:
16 |
17 | - Data entry forms
18 | - Business logic
19 | - Validation logic
20 | - Real-time validation
21 | - CRUD
22 | - Relationships and relational integrity
23 | - Data denormalization
24 | - Async auto-complete
25 |
26 | In time it will also include
27 |
28 | - Security
29 | - Charts
30 | - Paper reporting
31 | - Excel export
32 | - Testing
33 |
34 | ## Er... Where's the code?
35 |
36 | If you haven't seen Meteor 1.3's recommended application structure yet you may be wondering where the actual code is.
37 |
38 | The UI Is here https://github.com/tomRedox/simpleCRM/tree/master/imports/ui and the API is here https://github.com/tomRedox/simpleCRM/tree/master/imports/api
39 |
40 | ## Technology
41 | This version of Simple CRM uses the following stack/technologies:
42 |
43 | - Meteor 1.3
44 | - MongoDB
45 | - React for the view layer
46 | - Redux for the application state
47 |
48 | ## Installation
49 | The instructions below assume you've already installed Meteor on your machine.
50 |
51 | 1. Clone this repository to your machine: `git clone https://github.com/tomRedox/simpleCRM.git`
52 | 2. Change to the project's directory: `cd simpleCRM`
53 | 3. Install the version of Meteor used by this project: `meteor update --release 1.3-beta.12` (you can have multiple versions of Meteor installed on a machine, so this shouldn't upset any other projects. Meteor stores which version to user for a particular project in the .meteor/release file)
54 | 4. Install the npm modules used by the project: `npm install`
55 |
56 | Then run the project with `meteor`. The login credentials are: email: default@admin.com password: default@admin.
57 |
58 | ## Meteor
59 | The app is running on Meteor 1.3 Beta 12 and will not work with Meteor 1.2.
60 |
61 | I've tried to adopt the standards laid down in the Meteor guide, especially:
62 |
63 | - Application structure that uses main.js entry points and puts the rest of the code in imports directories (ES2015)
64 | - Container pattern
65 | - Template level data subscriptions
66 | - Use of MDG validated methods
67 | - No AutoPublish or Insecure
68 |
69 | ## React/Redux
70 | The view layer is built in React, with each component in its own file. I've tried to follow React best practice, although defining exactly what constitutes best practice at present is difficult as React and Redux are still changing so often.
71 |
72 | I've used Redux for managing the application state. However, I do not put the stored data in the state, I leave MiniMongo out of Redux altogether. I do copy data into the state while it's being edited however, I do this to make the record being edited non-reactive during the edit (I don't want the data the user sees to change while they are editing it) and in order to allow real-time data validation.
73 |
74 | There is also a ["no-redux" branch](https://github.com/tomRedox/simpleCRM/tree/no-redux) that features the older working version of the system before I switched over to Redux. That may be interesting to those wishing to see what an app looks like with and without Redux. I won't be maintaining the no-redux branch, it's just for info.
75 |
76 | ## UI Design
77 | At the moment I've done the basics to get things up and running, but little in the way of design work has gone into the project so far.
78 |
79 | The design consists of:
80 |
81 | - Material design-esque theme from Bootswatch
82 | - Parts of the SB Admin 2 Bootstrap theme
83 |
84 | I have also experimented with the excellent [Material-UI React component library](http://www.material-ui.com/#/) and I may adopt that at some point. There is a separate branch for that work
85 |
86 | ## Security
87 |
88 | The app now has security based on Meteor's in-built security API. The login credentials are:
89 | email: default@admin.com
90 | password: default@admin
91 |
92 | ## Testing
93 | There are a couple of unit tests for the business logic in the lib folder. The testing has been built using the instructions in the [draft Testing chapter of the Meteor Guide](https://github.com/meteor/guide/blob/testing-modules-content/content/testing.md).
94 |
95 | To run the tests open up a second command window and type:
96 | `meteor test --driver-package avital:mocha --port 3100`
97 | This starts the test runner which will automatically rerun the tests as required.
98 |
99 | You can then view the test results at [http://localhost:3100/](http://localhost:3100/).
100 |
101 | ## Todo
102 | I need to feed the changes from our production dev back into this project, namely:
103 | - [ ] ES6 syntax
104 | - [ ] Functional stateless components
105 | - [ ] Meteor 1.3 final
106 | - [ ] Updates to the testing frameworks
107 |
108 | Other broad areas still to be looked at in the project are:
109 |
110 | - [ ] IsDirty checking to warn before navigation when changes have been made
111 | - [x] Unit tests
112 | - [ ] End-to-end/integration test
113 | - [ ] Add [Airbnb's linting](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb)
114 | - [ ] Security - only return and write data for logged in users
115 | - [ ] Security - add Admin role, only Admin to be able edit the Products list
116 | - [ ] Charts
117 | - [ ] Paper reporting
118 | - [ ] Excel export
119 | - [ ] Improve/fix the transition animations
120 | - [ ] Customer deletion
121 | - [ ] Customer search page - allow search on address, name and postcode
122 | - [ ] Pagination on All Customers and All Orders pages
123 | - [ ] Sorting options on lists
124 |
125 | ## Learning resources used in this solution
126 | I am hugely grateful to the Meteor community and the members of the SpaceDojo/Meteor Club Slack channel for all their help getting up to speed on everything.
127 |
128 | These are some of the many resources that have really helped me:
129 |
130 | ### General Meteor stuff
131 | - [Meteor Guide](http://guide.meteor.com/)
132 | - [Meteor Guide 1.3 (draft)](http://guide.meteor.com/v1.3)
133 | - [Meteor Forums](https://forums.meteor.com/)
134 | - [Rob Conery's Building a Realtime Web Application with Meteor.js Pluralsight course](https://app.pluralsight.com/library/courses/meteorjs-web-application/table-of-contents)
135 | - [SpaceDojo](http://spacedojo.com/) and the [SpaceDojo community](https://www.patreon.com/meteorclub?ty=c)
136 | - [Meteor Chef](https://themeteorchef.com/)
137 | - [Meteor Chef - Click to edit fields in react](https://themeteorchef.com/snippets/click-to-edit-fields-in-react/)
138 | - [Stack Overflow](http://stackoverflow.com/)
139 |
140 | ### React (non Meteor specific)
141 | - [Cory House's Building Applications with React and Flux Pluralsight course](https://app.pluralsight.com/library/courses/react-flux-building-applications/table-of-contents)
142 | - [Official React documentation](https://facebook.github.io/react/docs/getting-started.html)
143 | - [Official Redux documentation](http://redux.js.org/docs)
144 |
145 | ### React in Meteor
146 | - [The official guidance from the draft Meteor Guide for Meteor 1.3](http://guide.meteor.com/v1.3/react.html)
147 | - [The official React example app, main React branch](https://github.com/meteor/todos/tree/react)
148 | - [The official React example app, React with Tests branch](https://github.com/meteor/todos/tree/react-testing)
149 | - [Discussion of the Container pattern and how to stop React components rendering before the data is available](https://thoughts.spacedojo.com/meteor-1-3-and-react-composition-with-reactive-data-b0bb3282fea#.a5yqn4j83)
150 |
151 | ### Redux in Meteor - the path to enlightenment
152 | If you don't know Redux yet, then learn:
153 | - [Learn it from the master, Dan Abromov's EggHeads Redux video series](https://egghead.io/series/getting-started-with-redux)
154 |
155 | Start with this series:
156 | - [Abhi Aiyer's How we Redux series - Part 1](https://medium.com/modern-user-interfaces/how-we-redux-part-1-introduction-18a24c3b7efe#.4xzqhtyea)
157 | - [Abhi Aiyer's How we Redux series - Part 2](https://medium.com/modern-user-interfaces/how-we-redux-part-2-setup-c6aa726fa79e#.kpun54ox5)
158 | - [Abhi Aiyer's How we Redux series - Part 3](https://medium.com/modern-user-interfaces/how-we-redux-part-3-domain-890964824fec#.ujuwer38a)
159 | - [Abhi Aiyer's How we Redux series - Part 4](https://medium.com/modern-user-interfaces/how-we-redux-part-4-reducers-and-stores-f4a0ebcdc22a#.frb4di9zz)
160 | - [Abhi Aiyer's How we Redux series - Part 5](https://medium.com/modern-user-interfaces/how-we-redux-part-5-components-bddd737022e1#.1w2j8scwd)
161 | - [How we Redux example app repo](https://github.com/abhiaiyer91/How-We-Redux-Todos)
162 |
163 | But also open up this example and have it to refer to too:
164 | - [ffxSam's ffx-meteor-react-boilerplate - great thunk examples](https://github.com/ffxsam/ffx-meteor-react-boilerplate/blob/example/client/actions/colors.js)
165 |
166 | And also this is really useful:
167 | - [AdamBrodzinski's meteor-flux-helpers](https://github.com/AdamBrodzinski/meteor-flux-helpers/blob/master/flux-helpers.js)
168 | - [Discussion of meteor-flux-helpers](https://forums.meteor.com/t/flux-helpers-package/7814/5)
169 | - [Redux version of meteor-flux-helpers example app](https://github.com/AdamBrodzinski/meteor-flux-leaderboard/tree/redux)
170 |
171 | Other useful bits:
172 | - [Redux approach in Meteor discussion](https://forums.meteor.com/t/redux-approach-on-meteor/8441/17)
173 |
174 | ## About Redox Software Ltd
175 | We write bespoke line of business software for small and medium size businesses.
176 |
177 | Read all about us at [http://www.redox-software.co.uk/](http://www.redox-software.co.uk/).
178 |
179 |
--------------------------------------------------------------------------------