Last active
October 1, 2021 18:31
-
-
Save ulexxander/ba1063ffeb126e738ac32fb513581f19 to your computer and use it in GitHub Desktop.
Testing approach to write effector business logic only using factories.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
combine, | |
createEffect, | |
createEvent, | |
createStore, | |
Effect, | |
Event, | |
forward, | |
sample, | |
Store, | |
} from "effector"; | |
import { useStore } from "effector-react"; | |
import { nanoid } from "nanoid"; | |
import React, { createContext, useContext } from "react"; | |
import ReactDOM from "react-dom"; | |
import "./domains/logicInit"; | |
import "./tailwind.css"; | |
// Business logic | |
function sleep(ms: number) { | |
return new Promise((resolve) => setTimeout(resolve, ms)); | |
} | |
type Product = { | |
id: string; | |
title: string; | |
price: number; | |
}; | |
type CartProduct = Product & { | |
amount: number; | |
beingDeleted?: boolean; | |
}; | |
type AddProductPayload = { | |
product: Product; | |
amount: number; | |
}; | |
type CartService = { | |
$products: Store<CartProduct[]>; | |
addProduct: Event<AddProductPayload>; | |
removeProduct: Event<{ id: string }>; | |
recoverProduct: Event<{ id: string }>; | |
}; | |
type FormField<State> = { | |
$value: Store<State>; | |
set: Event<State>; | |
reset: Event<void>; | |
}; | |
function createFormField<State>(defaultState: State): FormField<State> { | |
const $value = createStore<State>(defaultState); | |
const set = createEvent<State>(); | |
const reset = createEvent<void>(); | |
$value.on(set, (_, newValue) => newValue).reset(reset); | |
return { | |
$value, | |
set, | |
reset, | |
}; | |
} | |
type NewProductFormState = { | |
title: string; | |
price: number | null; | |
amount: number | null; | |
}; | |
type NewProductForm = { | |
title: FormField<string>; | |
price: FormField<number | null>; | |
amount: FormField<number | null>; | |
$fields: Store<NewProductFormState>; | |
submit: Event<void>; | |
submitted: Event<NewProductFormState>; | |
}; | |
function createNewProductForm(): NewProductForm { | |
const title = createFormField<string>(""); | |
const price = createFormField<number | null>(null); | |
const amount = createFormField<number | null>(null); | |
const $fields = combine<NewProductFormState>({ | |
title: title.$value, | |
price: price.$value, | |
amount: amount.$value, | |
}); | |
const submit = createEvent(); | |
const submitted = createEvent<NewProductFormState>(); | |
sample({ | |
clock: submit, | |
source: $fields, | |
target: submitted, | |
}); | |
return { | |
title, | |
price, | |
amount, | |
$fields, | |
submit, | |
submitted, | |
}; | |
} | |
function createCartService(newProductForm: NewProductForm): CartService { | |
const $products = createStore<CartProduct[]>([]); | |
const addProduct = createEvent<AddProductPayload>(); | |
const removeProduct = createEvent<{ id: string }>(); | |
const recoverProduct = createEvent<{ id: string }>(); | |
addProduct.watch(() => { | |
console.log("add product"); | |
}); | |
$products | |
.on(addProduct, (products, { product, amount }) => [ | |
...products, | |
{ ...product, amount }, | |
]) | |
.on(removeProduct, (products, { id }) => | |
products.map((product) => | |
product.id === id ? { ...product, beingDeleted: true } : product | |
) | |
) | |
.on(recoverProduct, (products, { id }) => | |
products.map((product) => | |
product.id === id ? { ...product, beingDeleted: false } : product | |
) | |
); | |
// adding fake product for now :) | |
forward({ | |
from: newProductForm.submitted, | |
to: addProduct.prepend((form) => ({ | |
product: { | |
id: nanoid(), | |
title: form.title, | |
price: form.price || 0, | |
}, | |
amount: form.amount || 0, | |
})), | |
}); | |
return { | |
$products, | |
addProduct, | |
removeProduct, | |
recoverProduct, | |
}; | |
} | |
type Order = { | |
id: string; | |
cartProducts: CartProduct[]; | |
}; | |
type AddOrderPayload = { | |
cartProducts: CartProduct[]; | |
}; | |
type OrderService = { | |
$orders: Store<Order[] | null>; | |
loadOrders: Event<void>; | |
sendOrder: Event<void>; | |
getOrdersFx: Effect<void, Order[]>; | |
postOrderFx: Effect<AddOrderPayload, { id: string }>; | |
}; | |
function createOrderService(cartService: CartService): OrderService { | |
const $orders = createStore<Order[] | null>(null); | |
const loadOrders = createEvent(); | |
const sendOrder = createEvent(); | |
const getOrdersFx = createEffect<void, Order[]>(async () => { | |
await sleep(100); | |
const orders: Order[] = [ | |
{ | |
id: nanoid(), | |
cartProducts: [ | |
{ | |
id: nanoid(), | |
title: "Abc product", | |
price: 100, | |
amount: 5, | |
}, | |
], | |
}, | |
]; | |
return orders; | |
}); | |
const postOrderFx = createEffect<AddOrderPayload, { id: string }>( | |
async () => { | |
await sleep(50); | |
const result: { id: string } = { | |
id: nanoid(), | |
}; | |
return result; | |
} | |
); | |
$orders.on(getOrdersFx.doneData, (_, orders) => orders); | |
forward({ | |
from: [loadOrders, postOrderFx.done], | |
to: getOrdersFx, | |
}); | |
sample({ | |
clock: sendOrder, | |
source: cartService.$products, | |
fn(cartProducts) { | |
const order: AddOrderPayload = { | |
cartProducts, | |
}; | |
return order; | |
}, | |
target: postOrderFx, | |
}); | |
return { | |
$orders, | |
loadOrders, | |
sendOrder, | |
getOrdersFx, | |
postOrderFx, | |
}; | |
} | |
// Providers for business logic inside view layer | |
const newProductForm = createNewProductForm(); | |
const cartService = createCartService(newProductForm); | |
const orderService = createOrderService(cartService); | |
const newProductFormContext = createContext(newProductForm); | |
const cartServiceContext = createContext(cartService); | |
const orderServiceContext = createContext(orderService); | |
function useNewProductForm() { | |
return useContext(newProductFormContext); | |
} | |
function useCartService() { | |
return useContext(cartServiceContext); | |
} | |
function useOrderService() { | |
return useContext(orderServiceContext); | |
} | |
const NewProductFormProvider: React.FC = ({ children }) => ( | |
<newProductFormContext.Provider value={newProductForm}> | |
{children} | |
</newProductFormContext.Provider> | |
); | |
const CartServiceProvider: React.FC = ({ children }) => ( | |
<cartServiceContext.Provider value={cartService}> | |
{children} | |
</cartServiceContext.Provider> | |
); | |
const OrderServiceProvider: React.FC = ({ children }) => ( | |
<orderServiceContext.Provider value={orderService}> | |
{children} | |
</orderServiceContext.Provider> | |
); | |
// View layer | |
const CartProducts: React.FC = () => { | |
const cart = useCartService(); | |
const products = useStore(cart.$products); | |
if (!products.length) { | |
return <p>Cart is empty</p>; | |
} | |
return ( | |
<ul className="space-y-4"> | |
{products.map((product) => ( | |
<li key={product.id}> | |
<h4>{product.title}</h4> | |
<p> | |
${product.price} x {product.amount} | |
</p> | |
</li> | |
))} | |
</ul> | |
); | |
}; | |
function numberOrZero(value: number) { | |
console.log("number or zero"); | |
return Number.isNaN(value) ? 0 : value; | |
} | |
const AddCartProduct: React.FC = () => { | |
const form = useNewProductForm(); | |
const { title, price, amount } = useStore(form.$fields); | |
return ( | |
<form | |
className="flex flex-col items-start space-y-4" | |
onSubmit={(e) => { | |
e.preventDefault(); | |
form.submit(); | |
}} | |
> | |
<input | |
type="text" | |
className="text-white bg-gray-700 border-gray-500" | |
value={title} | |
onChange={(e) => form.title.set(e.target.value)} | |
placeholder="Title" | |
/> | |
<input | |
type="number" | |
className="text-white bg-gray-700 border-gray-500" | |
value={price || ""} | |
onChange={(e) => form.price.set(numberOrZero(e.target.valueAsNumber))} | |
placeholder="Price" | |
/> | |
<input | |
type="number" | |
className="text-white bg-gray-700 border-gray-500" | |
value={amount || ""} | |
onChange={(e) => form.amount.set(numberOrZero(e.target.valueAsNumber))} | |
placeholder="Amount" | |
/> | |
<button type="submit">Add product</button> | |
</form> | |
); | |
}; | |
const Cart: React.FC = () => { | |
return ( | |
<section> | |
<h2>Cart</h2> | |
<div className="mt-4"> | |
<CartProducts /> | |
</div> | |
<h3 className="mt-8">Add cart product</h3> | |
<div className="mt-4"> | |
<AddCartProduct /> | |
</div> | |
</section> | |
); | |
}; | |
const EffectorServices: React.FC = () => { | |
return ( | |
<div className="w-full min-h-screen text-white bg-gray-900"> | |
<main className="p-8"> | |
<Cart /> | |
</main> | |
</div> | |
); | |
}; | |
ReactDOM.render( | |
<React.StrictMode> | |
<CartServiceProvider> | |
<OrderServiceProvider> | |
<EffectorServices /> | |
</OrderServiceProvider> | |
</CartServiceProvider> | |
</React.StrictMode>, | |
document.getElementById("root") | |
); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment