change product to dump to object before storing in redix. TODO: solve dimensions issue.

This commit is contained in:
Jordan 2024-06-30 09:37:27 -07:00
parent de0167e9e5
commit 408a996fe7
15 changed files with 577 additions and 186 deletions

View File

@ -10,7 +10,7 @@ import { setupStore } from '../store';
export default function TabLayout() { export default function TabLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const store = setupStore({ const store = setupStore({
products: fixtures products: fixtures.map(p => p.asObject)
}); });
return ( return (
<Provider store={store}> <Provider store={store}>

View File

@ -23,9 +23,10 @@ export default function HomeScreen() {
if (!activeProduct) return; if (!activeProduct) return;
const l = Number.parseInt(length); const l = Number.parseInt(length);
const w = Number.parseInt(width); const w = Number.parseInt(width);
console.log("l=%d, w=%d", l, w); // console.log("l=%d, w=%d", l, w);
const u = units; const u = units;
const d: dimensions_t = activeProduct.area ? { l, w, u } : { l, u }; const d: dimensions_t = activeProduct.area ? { l, w, u } : { l, u };
try {
const p = activeProduct.priceFor(d); const p = activeProduct.priceFor(d);
console.log("set price %s", p); console.log("set price %s", p);
const s = p.toLocaleString(undefined, { const s = p.toLocaleString(undefined, {
@ -33,7 +34,12 @@ export default function HomeScreen() {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}) })
setPrice(s == "NaN" ? "0.00" : s); setPrice(s == "NaN" ? "0.00" : s);
}, 10); } catch (err) {
console.log(activeProduct);
console.error(err)
return null;
}
}, 50);
return function () { return function () {
clearInterval(iv); clearInterval(iv);
} }
@ -67,14 +73,14 @@ export default function HomeScreen() {
<Text style={styles.unitHints}>{units}</Text> <Text style={styles.unitHints}>{units}</Text>
</View>) </View>)
} }
</View>
) : (<Text>Please choose a product</Text>)}
</View>
<View style={styles.unitSelector}> <View style={styles.unitSelector}>
<Button title="in" onPress={() => setUnits("in")} color={units === "in" ? "gray" : "blue"} /> <Button title="in" onPress={() => setUnits("in")} color={units === "in" ? "gray" : "blue"} />
<Button title="ft" onPress={() => setUnits("ft")} color={units === "ft" ? "gray" : "blue"} /> <Button title="ft" onPress={() => setUnits("ft")} color={units === "ft" ? "gray" : "blue"} />
</View> </View>
</View> </View>
) : (<Text>Please choose a product</Text>)}
</View>
</View>
<FlatList <FlatList
data={products} data={products}
style={styles.productSelectorFlatList} style={styles.productSelectorFlatList}

View File

@ -3,14 +3,14 @@ import { configureStore } from '@reduxjs/toolkit';
import { rememberReducer, rememberEnhancer } from 'redux-remember'; import { rememberReducer, rememberEnhancer } from 'redux-remember';
import reducers from "@/features/product/productSlice" import reducers from "@/features/product/productSlice"
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Product, } from "@/lib/product"; import { Product, ProductData, } from "@/lib/product";
const rememberedKeys = ['products']; const rememberedKeys = ['products'];
const rootReducer = reducers; const rootReducer = reducers;
export function setupStore(preloadedState = { export function setupStore(preloadedState = {
products: [] as Product[], products: [] as ProductData[],
}) { }) {
return configureStore({ return configureStore({
reducer: rememberReducer(reducers), reducer: rememberReducer(reducers),

View File

@ -4,31 +4,44 @@ import React from "react";
import { useState } from "react"; import { useState } from "react";
import { StyleSheet, Text, TextInput, TouchableHighlight, View } from "react-native"; import { StyleSheet, Text, TextInput, TouchableHighlight, View } from "react-native";
export type ProductAttributeChangeFunc = (product_id: string, key: string, newValue: string) => any; export type ProductAttributeChangeFunc = (key: string, newValue: string) => any;
export type ProductAttributeDeleteFunc = (product_id: string, key: string) => any; export type ProductAttributeDeleteFunc = (key: string) => any;
export type ChangeAttributeFunction = (oldKey : string, newKey : string) => any;
export type ProductAttributeProps = { product: Product, attributeKey: string, attributeValue: string, onChange?: ProductAttributeChangeFunc, onDelete?: ProductAttributeChangeFunc, }; export type ProductAttributeProps = {
attributeKey: string,
attributeValue: string,
onChangeAttributeKey?: ChangeAttributeFunction,
onChangeAttribute?: ProductAttributeChangeFunc,
onDelete?: ProductAttributeChangeFunc,
};
export const ProductAttributeEditor = ({ product, attributeKey: key, attributeValue: value, onDelete, onChange }: ProductAttributeProps) => { export const ProductAttributeEditor = ({ attributeKey, attributeValue, onDelete, onChangeAttributeKey, onChangeAttribute }: ProductAttributeProps) => {
const [doEdit, setDoEdit] = useState(true);
const [newValue, setNewValue] = useState(value);
const doChange = (e: any) => { const doChangeKey = (e: any) => {
setNewValue(e); onChangeAttributeKey && onChangeAttributeKey(attributeKey, e);
onChange && onChange(product.id, key, e); }
const doChangeValue = (e: any) => {
onChangeAttribute && onChangeAttribute(attributeKey, e);
} }
return ( return (
<View> <View>
<View style={styles.productAttributeRow}> <View style={styles.productAttributeRow}>
<Text style={styles.key}>{key}</Text>
<TextInput <TextInput
value={newValue} defaultValue={attributeKey}
onChangeText={doChange} onChangeText={doChangeKey}
style={styles.value}
aria-label="Edit Key"
/>
<TextInput
defaultValue={attributeValue}
onChangeText={doChangeValue}
style={styles.value} style={styles.value}
aria-label="Edit Value" /> aria-label="Edit Value" />
<TouchableHighlight <TouchableHighlight
onPress={() => onDelete && onDelete(product.id, key, value)} onPress={() => onDelete && onDelete(attributeKey, attributeValue)}
aria-label="Delete Attribute" aria-label="Delete Attribute"
style={{ backgroundColor: "darkred", borderRadius: 5, margin: 5, padding: 5, }}> style={{ backgroundColor: "darkred", borderRadius: 5, margin: 5, padding: 5, }}>
<Ionicons name="trash-bin-outline" size={30} color={"white"} /> <Ionicons name="trash-bin-outline" size={30} color={"white"} />

View File

@ -1,6 +1,6 @@
import { useAppDispatch, useAppSelector } from "@/app/store" import { useAppDispatch, useAppSelector } from "@/app/store"
import { deleteProduct, selectProducts, updateProduct } from "@/features/product/productSlice" import { addAttribute, changeKey, deleteAttribute, deleteProduct, selectProductIds, selectProducts, updateAttribute, updateDimensions, updatePrice, updateProduct } from "@/features/product/productSlice"
import { Product } from "@/lib/product"; import { Id, Product, dimensions_t } from "@/lib/product";
import { FlatList, SafeAreaView, StyleSheet, Text } from "react-native"; import { FlatList, SafeAreaView, StyleSheet, Text } from "react-native";
import { ProductEditorItem } from "./ProductEditorItem"; import { ProductEditorItem } from "./ProductEditorItem";
@ -13,23 +13,50 @@ export const ProductEditor = ({}) => {
dispatch(deleteProduct(product_id)); dispatch(deleteProduct(product_id));
} }
function onProductUpdated(product_id: string, product: Product) { function onAttributeDelete(product_id: string, attribute: string) {
dispatch(updateProduct(product)); dispatch(deleteAttribute({product_id: product_id, attribute}));
} }
function onAttributeUpdated(product_id: string, attribute: string, value: string) {
dispatch(updateAttribute({product_id, attributeKey: attribute, attributeValue: value}));
}
function onAttributeAdded(product_id: Id) {
console.log("Adding attribute to %s", product_id);
dispatch(addAttribute(product_id));
}
function onPriceUpdated(product_id: string, pricePerUnit: number) {
dispatch(updatePrice({product_id, pricePerUnit}));
}
function onAttributeKeyChanged(product_id : string, oldKey : string, newKey : string) {
dispatch(changeKey({product_id, oldKey, newKey}))
}
function onDimensionUpdated(product_id: string, dimensions: dimensions_t) {
dispatch(updateDimensions({product_id, dimensions}));
}
return ( return (
<SafeAreaView> <SafeAreaView>
<h1 style={styles.h1}>Edit Products</h1> <h1 style={styles.h1}>Edit Products</h1>
<FlatList <FlatList
data={products} data={products}
keyExtractor={(p, i) => `product-${p.id}`}
renderItem={ renderItem={
({item}) => { ({item}) => {
return ( return (
<ProductEditorItem <ProductEditorItem
product={item} product={item}
onProductDeleted={onProductDeleted} onProductDeleted={onProductDeleted}
onProductUpdated={onProductUpdated} onAttributeDeleted={onAttributeDelete}
onAttributeKeyChanged={onAttributeKeyChanged}
onAttributeUpdated={onAttributeUpdated}
onAttributeAdded={onAttributeAdded}
onPriceUpdated={onPriceUpdated}
onDimensionsUpdated={onDimensionUpdated}
/> />
) )
} }

View File

@ -1,41 +1,86 @@
import { Product } from "@/lib/product" import { Id, Product, dimensions_t } from "@/lib/product"
import { useState } from "react" import { useState } from "react"
import { FlatList, StyleSheet, Text, TouchableHighlight, View } from "react-native" import { Button, FlatList, StyleSheet, Text, Touchable, TouchableHighlight, View } from "react-native"
import { ProductAttributeEditor } from "./ProductAttributeEditor"; import { ProductAttributeEditor } from "./ProductAttributeEditor";
import { TextInput } from "react-native-gesture-handler"; import { TextInput } from "react-native-gesture-handler";
import { useAppSelector } from "@/app/store";
import rfdc from "rfdc";
import SelectDropdown from "react-native-select-dropdown";
import { Ionicons } from "@expo/vector-icons";
export type ProductUpdatedFunc = (product_id: string, product: Product) => any; export type ProductAddedFunc = () => any;
export type ProductDeletedFunc = (product_id: string) => any; export type ProductDeletedFunc = (product_id: Id) => any;
export type AttributeAddedFunc = (product_id: Id) => any;
export type AttributeKeyUpdatedFunc = (product_id: Id, oldKey: string, newKey: string) => any;
export type AttributeUpdatedFunc = (product_id: Id, attribute: string, value: string) => any;
export type AttributeDeletedFunc = (product_id: Id, attribute: string) => any;
export type PriceUpdatedFunc = (product_id: Id, price: number) => any;
export type DimensionUpdatedFunc = (product_id: Id, dimension: dimensions_t) => any;
export type ProductEditorItemProps = { export type ProductEditorItemProps = {
product: Product, product: Product,
onProductUpdated?: ProductUpdatedFunc, onProductAdded?: ProductAddedFunc,
onProductDeleted?: ProductDeletedFunc, onProductDeleted?: ProductDeletedFunc,
onAttributeAdded?: AttributeAddedFunc,
onAttributeKeyChanged?: AttributeKeyUpdatedFunc,
onAttributeUpdated?: AttributeUpdatedFunc,
onAttributeDeleted?: AttributeDeletedFunc,
onPriceUpdated?: PriceUpdatedFunc,
onDimensionsUpdated?: DimensionUpdatedFunc,
} }
export const ProductEditorItem = ({ product, onProductUpdated, onProductDeleted }: ProductEditorItemProps) => { export const ProductEditorItem = (props: ProductEditorItemProps) => {
const [showAttributes, setShowAttributes] = useState(false); const [showAttributes, setShowAttributes] = useState(false);
const [doEditName, setDoEditName] = useState(false); const product = props.product;
const [newName, setNewName] = useState(product.attributes.name || `Product ${product.id}`);
function updateName(name : string) { function onAttributeChanged(key: string, newValue: string) {
setNewName(name); props.onAttributeUpdated && props.onAttributeUpdated(product.id, key, newValue);
product.attributes["name"] = name;
onProductUpdated && onProductUpdated(product.id, product);
} }
function onAttributeChanged(product_id: string, key: string, newValue: string) { function onAttributeKeyChanged(oldKey: string, newKey: string) {
product.attributes[key] = newValue; props.onAttributeKeyChanged && props.onAttributeKeyChanged(product.id, oldKey, newKey);
onProductUpdated && onProductUpdated(product_id, product);
} }
function onAttributeDelete(product_id: string, key: string) { function onAttributeDelete(key: string) {
product.removeAttribute(key); props.onAttributeDeleted && props.onAttributeDeleted(product.id, key);
onProductDeleted && onProductDeleted(product_id);
} }
function onPricePerUnitChange(pricePerUnit: string) {
props.onPriceUpdated && props.onPriceUpdated(product.id, parseFloat(pricePerUnit) || parseInt(pricePerUnit));
}
function onUnitsChanged(newUnits: "foot" | "inch") {
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...((product.area || product.length) as dimensions_t),
u: newUnits,
})
}
function onChangeLength(len: string) {
const l = parseFloat(len) || parseInt(len);
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...((product.area || product.length) as dimensions_t),
l,
})
}
function onChangeWidth(width: string) {
const w = parseFloat(width) || parseInt(width);
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
...((product.area || product.length) as dimensions_t),
w,
})
}
function onDeleteProduct() {
props.onProductDeleted && props.onProductDeleted(product.id);
}
const length = new String(product.area?.l || product.length?.l || "0") as string;
const width = new String(product.area?.w || "") as string;
const dimension = product.area?.u || product.length?.u || "foot";
return ( return (
<View> <View>
<TouchableHighlight <TouchableHighlight
@ -43,24 +88,67 @@ export const ProductEditorItem = ({ product, onProductUpdated, onProductDeleted
aria-label="Product Item" aria-label="Product Item"
style={styles.productItemName} style={styles.productItemName}
> >
<Text style={styles.productNameText}>{newName}</Text> <Text style={styles.productNameText}>{product.attributes.name || `Product ${product.id}`}</Text>
</TouchableHighlight>
<TouchableHighlight
onPress={() => onDeleteProduct()}
aria-label="delete product"
style={styles.deleteProductHighlight}
>
<Ionicons style={styles.deleteProductButton} name="trash-outline" />
</TouchableHighlight> </TouchableHighlight>
{showAttributes && {showAttributes &&
( (
<View>
<View style={styles.priceSpecWrapper}>
<Text style={styles.priceLabel}></Text>
<TextInput inputMode="decimal"
defaultValue={new String(product.pricePerUnit) as string}
aria-label="price per unit"
onChangeText={onPricePerUnitChange}
style={styles.priceInput}
/>
<Text style={styles.per}>per</Text>
<Text style={styles.unitsLabel}>Units: </Text>
<select
onChange={(e) => onUnitsChanged(e.target.value as "foot" | "inch")}
style={styles.unitsSelect}
aria-label="units">
<option value="foot" selected={dimension === "foot"}>feet</option>
<option value="inch" selected={dimension === "inch"}>inches</option>
</select>
<TextInput
inputMode="decimal"
defaultValue={length}
onChangeText={onChangeLength}
style={styles.lengthInput}
aria-label="length"
/>
<Text>x</Text>
<TextInput
inputMode="decimal"
defaultValue={width}
onChangeText={onChangeWidth}
style={styles.widthInput}
aria-label="width"
/>
</View>
<Button title="+ Add Attribute" onPress={() => props.onAttributeAdded && props.onAttributeAdded(product.id)} />
<FlatList <FlatList
style={styles.productAttributesList} style={styles.productAttributesList}
data={product.attributesAsList} data={Object.entries(product.attributes)}
renderItem={({ item }) => ( renderItem={({ item }) => (
<ProductAttributeEditor <ProductAttributeEditor
product={product} attributeKey={item[0] || "some key"}
attributeKey={item.key || "some key"} attributeValue={item[1]}
attributeValue={item.value} onChangeAttributeKey={onAttributeKeyChanged}
onChange={onAttributeChanged} onChangeAttribute={onAttributeChanged}
onDelete={onAttributeDelete} onDelete={onAttributeDelete}
/> />
)} )}
keyExtractor={(item) => `${product.id}-${item.key}`} keyExtractor={(item, i) => `${product.id}-${i}`}
/> />
</View>
) )
} }
</View> </View>
@ -68,6 +156,36 @@ export const ProductEditorItem = ({ product, onProductUpdated, onProductDeleted
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
deleteProductHighlight: {
},
deleteProductButton: {
},
priceSpecWrapper: {
},
priceLabel: {
},
priceInput: {
},
per: {
},
unitsLabel: {
},
unitsSelect: {
},
lengthInput: {
},
widthInput: {
},
productNameText: { productNameText: {
paddingLeft: 10, paddingLeft: 10,
paddingRight: 10, paddingRight: 10,

View File

@ -6,10 +6,12 @@ import React from "react";
import { emitTypingEvents } from "@testing-library/react-native/build/user-event/type/type"; import { emitTypingEvents } from "@testing-library/react-native/build/user-event/type/type";
describe("Product editor tests", () => { describe("Product editor tests", () => {
const productName = "Fun Product";
it("Product attributes can be deleted", async () => { it("Product attributes can be deleted", async () => {
const product = new Product( const product = new Product(
100, 100,
area("squareFoot", 4 * 7) {l: 100, u: "foot"},
{"name" : productName}
); );
const onChange = jest.fn(); const onChange = jest.fn();
const onDelete = jest.fn(); const onDelete = jest.fn();
@ -18,7 +20,7 @@ describe("Product editor tests", () => {
attributeKey="name" attributeKey="name"
attributeValue="product" attributeValue="product"
product={product} product={product}
onChange={onChange} onChangeAttribute={onChange}
onDelete={onDelete} onDelete={onDelete}
/>); />);
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull(); expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
@ -26,25 +28,28 @@ describe("Product editor tests", () => {
expect(onDelete).toHaveBeenCalled(); expect(onDelete).toHaveBeenCalled();
}); });
it("Product attributes can be modified", async () => { it("Product attributes can be modified", async () => {
const productName = "Fun Product";
const product = new Product( const product = new Product(
100, 100,
area("squareFoot", 4 * 7), {l: 100, u: "foot"},
{ name: productName }, {"name" : productName}
); );
const onChange = jest.fn(); const onChange = jest.fn();
const onDelete = jest.fn(); const onDelete = jest.fn();
const onKeyChange = jest.fn();
render( render(
<ProductAttributeEditor <ProductAttributeEditor
attributeKey="Name" attributeKey="old test key"
attributeValue="product" attributeValue="old test value"
product={product} onChangeAttribute={onChange}
onChange={onChange}
onDelete={onDelete} onDelete={onDelete}
onChangeAttributeKey={onKeyChange}
/>); />);
fireEvent.press(screen.getByText("product")); // Use getByText instead of findByText fireEvent.changeText(screen.getByLabelText("Edit Key"), "new test key");
expect(onKeyChange).toHaveBeenCalled();
fireEvent.changeText(screen.getByLabelText("Edit Value"), "new name"); fireEvent.changeText(screen.getByLabelText("Edit Value"), "new name");
expect(onChange).toHaveBeenCalled(); expect(onChange).toHaveBeenCalled();
fireEvent.press(screen.getByLabelText("Delete Attribute"));
expect(onDelete).toHaveBeenCalled();
}) })
}) })

View File

@ -1,25 +1,51 @@
import { renderWithProviders } from "@/lib/rendering"; import { renderWithProviders } from "@/lib/rendering";
import { ProductEditor } from "@/components/ProductEditor"; import { ProductEditor } from "@/components/ProductEditor";
import {products as fixtures} from "@/__fixtures__/initialProducts"; import { act, fireEvent, screen } from "@testing-library/react-native";
import { screen } from "@testing-library/react-native";
import { selectProducts } from "@/features/product/productSlice"; import { selectProducts } from "@/features/product/productSlice";
import { Product } from "@/lib/product";
describe("ProductEditor", () => { describe("ProductEditor", () => {
const productName = "Flooring"
const mockProduct = new Product(
25,
{ l: 4, w: 8, u: "foot" },
{ name: productName },
)
it("renders correctly", async () => { it("renders correctly", async () => {
const {store} = renderWithProviders(<ProductEditor />, { const { store } = renderWithProviders(<ProductEditor />, {
products: fixtures, products: [
mockProduct.asObject,
],
}); });
const state1 = store.getState(); const state1 = store.getState();
const products = selectProducts(state1); let products = selectProducts(state1);
expect(products).toHaveLength(6); expect(products).toHaveLength(1);
// Check if the product names are rendered // Check if the product names are rendered
expect(screen.getByText(products[0].attributes.name as string)).toBeTruthy(); expect(screen.getByText(products[0].attributes.name as string)).toBeTruthy();
expect(screen.getByText(products[1].attributes.name as string)).toBeTruthy();
expect(screen.getByText(products[2].attributes.name as string)).toBeTruthy(); // Start to edit a product
expect(screen.getByText(products[3].attributes.name as string)).toBeTruthy(); fireEvent.press(screen.getByText(productName));
// Change properties of the product to make sure it's updated in the store
act(() => {
fireEvent.changeText(screen.getByLabelText("length"), "16");
})
products = selectProducts(store.getState());
expect(products[0].dimensions.l).toBe(16);
act(() => {
fireEvent.changeText(screen.getByLabelText("width"), "32");
})
products = selectProducts(store.getState());
expect(products[0].dimensions.w).toBe(32);
fireEvent.press(screen.getByLabelText("delete product"));
products = selectProducts(store.getState());
expect(products.length).toBe(0);
}); });
}); });

View File

@ -3,22 +3,36 @@ import { render, fireEvent, screen } from '@testing-library/react-native';
import { ProductEditorItem } from '../ProductEditorItem'; import { ProductEditorItem } from '../ProductEditorItem';
import { Product } from '@/lib/product'; import { Product } from '@/lib/product';
import { area } from 'enheter'; import { area } from 'enheter';
import { renderWithProviders } from '@/lib/rendering';
describe('ProductEditorItem', () => { describe('ProductEditorItem', () => {
const productName = "Product 1";
const mockProduct = new Product( const mockProduct = new Product(
25, 25,
area("squareFoot", 4 * 8), {l: 4, u: 'feet'},
{"name": "Product 1"}, {"name": productName},
) )
const mockOnProductUpdated = jest.fn(); const onAttributeAdded = jest.fn();
const mockOnProductDeleted = jest.fn(); const mockOnProductDeleted = jest.fn();
const onAttributeDeleted = jest.fn();
const onAttributeKeyChanged = jest.fn();
const onAttributeUpdated = jest.fn();
const onProductAdded = jest.fn();
const onPriceUpdated = jest.fn();
const onDimensionUpdated = jest.fn();
it('renders correctly', () => { it('renders correctly', () => {
render( render(
<ProductEditorItem <ProductEditorItem
product={mockProduct} product={mockProduct}
onProductUpdated={mockOnProductUpdated} onAttributeAdded={onAttributeAdded}
onAttributeDeleted={onAttributeDeleted}
onAttributeKeyChanged={onAttributeKeyChanged}
onAttributeUpdated={onAttributeUpdated}
onProductAdded={onProductAdded}
onPriceUpdated={onPriceUpdated}
onDimensionsUpdated={onDimensionUpdated}
onProductDeleted={mockOnProductDeleted} onProductDeleted={mockOnProductDeleted}
/> />
); );
@ -26,15 +40,37 @@ describe('ProductEditorItem', () => {
}); });
it('calls onProductUpdated when TouchableHighlight is pressed', () => { it('calls onProductUpdated when TouchableHighlight is pressed', () => {
render( const {store} = renderWithProviders(
<ProductEditorItem <ProductEditorItem
product={mockProduct} product={mockProduct}
onProductUpdated={mockOnProductUpdated} onAttributeAdded={onAttributeAdded}
onAttributeDeleted={onAttributeDeleted}
onAttributeKeyChanged={onAttributeKeyChanged}
onAttributeUpdated={onAttributeUpdated}
onProductAdded={onProductAdded}
onPriceUpdated={onPriceUpdated}
onDimensionsUpdated={onDimensionUpdated}
onProductDeleted={mockOnProductDeleted} onProductDeleted={mockOnProductDeleted}
/> />, {
products: [mockProduct],
}
); );
fireEvent.press(screen.getByText("Product 1")); fireEvent.press(screen.getByText("Product 1"));
expect(screen.getByText('name')).toBeTruthy(); expect(screen.getByLabelText("units")).toBeTruthy();
expect(screen.getAllByText('Product 1').length).toEqual(2); expect(screen.getByLabelText("Edit Key")).toBeTruthy();
expect(screen.getAllByLabelText("Edit Value").length).toEqual(1);
// Now start modifying the properties.
fireEvent.changeText(screen.getByLabelText("price per unit"), "40.00");
expect(onPriceUpdated).toHaveBeenCalled();
fireEvent.changeText(screen.getByLabelText("length"), "12");
expect(onDimensionUpdated).toHaveBeenCalled();
fireEvent.changeText(screen.getByLabelText("width"), "12");
expect(onDimensionUpdated).toHaveBeenCalled();
fireEvent.press(screen.getByLabelText("delete product"));
expect(mockOnProductDeleted).toHaveBeenCalled();
}); });
}); });

View File

@ -1,17 +1,36 @@
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Id, Product } from '@/lib/product'; import { area_t, dimensions_t, Id, length_t, Product, ProductData } from '@/lib/product';
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import { RootState } from '@/app/store'; import { RootState } from '@/app/store';
import { classToPlain, plainToClass } from 'class-transformer';
const initialState = { const initialState = {
products: [] as Product [], products: [] as ProductData[],
} }
export type UpdateAttribute = {
product_id: Id,
attributeKey: string,
attributeValue: any,
}
export type UpdateAttributeKey = {
product_id: Id,
oldKey: string,
newKey: string,
}
export type AddAttribute = {
product_id: Id,
}
const cp = (obj: any) => JSON.parse(JSON.stringify(obj));
const productsState = createSlice({ const productsState = createSlice({
name: 'products-slice', name: 'products-slice',
initialState, initialState,
reducers: { reducers: {
createProduct(state, action: PayloadAction<Product>) { createProduct(state, action: PayloadAction<ProductData>) {
if (!state) { if (!state) {
return initialState return initialState
} }
@ -20,39 +39,129 @@ const productsState = createSlice({
state.products = [...state.products, action.payload]; state.products = [...state.products, action.payload];
return state; return state;
}, },
updateProduct(state, action: PayloadAction<Product>) {
if (!state) return initialState;
const product = action.payload;
if (!product.id) {
throw new Error("Product has no ID");
}
state.products = state.products.map((prod) => {
return prod.id === product.id ? product : prod;
})
return state;
},
deleteProduct(state, action: PayloadAction<Id>) { deleteProduct(state, action: PayloadAction<Id>) {
if (!state) return initialState; if (!state) return initialState;
state.products = state.products.filter((prod) => { return {
prod.id !== action.payload; ...state,
}) products: [...state.products.filter((prod) => {
return state; return prod.id?.valueOf() !== action.payload.valueOf();
})],
} }
},
updateAttribute(state, action: PayloadAction<UpdateAttribute>) {
const { product_id, attributeKey, attributeValue } = action.payload
if (!state) return initialState;
return {
...state,
products: state.products.map(prod => {
if (prod.id !== product_id) return prod;
const attributes = cp(prod.attributes);
attributes[attributeKey] = attributeValue;
return {
...prod,
attributes,
}
})
};
},
changeKey(state, action: PayloadAction<UpdateAttributeKey>) {
if (!state) return initialState;
const { product_id, oldKey, newKey } = action.payload
return {
...state,
products: state.products.map(prod => {
if (prod.id !== product_id) return prod;
const attributes = cp(prod.attributes);
attributes[newKey] = attributes[oldKey];
delete attributes[oldKey];
attributes.id = prod.id;
return {
...prod,
attributes,
}
})
};
},
addAttribute(state, action: PayloadAction<Id>) {
if (!state) return initialState;
const product_id = action.payload;
state.products = state.products.map(prod => {
if (prod.id !== product_id) return prod;
const i = (Object.keys(prod.attributes || {}).filter(k => k.match(/attribute [\d]+/)) || []).length;
const newAttribute = `attribute ${i + 1}`;
return {
...prod,
attributes: {
...prod.attributes,
[newAttribute]: `value`,
}
}
});
return state;
},
deleteAttribute(state, action: PayloadAction<{ product_id: Id, attribute: string }>) {
if (!state) return initialState;
const { product_id, attribute } = action.payload;
return {
...state,
products: state.products.map(prod => {
if (prod.id !== product_id) return prod;
const attributes = Object.fromEntries(Object.entries(prod).filter(([k, v]) => (k !== attribute)));
return {
...prod,
attributes,
}
}),
};
},
updatePrice(state, action: PayloadAction<{ product_id: Id, pricePerUnit: number }>) {
if (!state) return initialState;
const { product_id, pricePerUnit } = action.payload;
state.products = state.products.map(prod => {
if (prod.id !== product_id) return prod;
prod.pricePerUnit = pricePerUnit;
return prod;
});
return state;
},
updateDimensions(state, action: PayloadAction<{ product_id: Id, dimensions: dimensions_t }>) {
if (!state) return initialState;
const { product_id, dimensions } = action.payload;
console.log("Changing dimensions: %o", action.payload);
return {
...state,
products: state.products.map(prod => {
if (prod.id !== product_id) return prod;
return {
...prod,
dimensions,
}
}),
};
},
} }
}); });
export const selectProducts = (state : RootState) => { export const selectProducts = (state: RootState) => {
return state.products; return state.products.map(Product.fromObject);
} }
export const productIds = (state : RootState) => { export const selectProductIds = createSelector([selectProducts], products => {
return state.products.map(p => p.id); return products.map(p => p.id);
} })
export const getProductById = (state : RootState, id : Id) => {
return state.products.find(x => x.id === id);
}
export const selectProductAttributes = createSelector([selectProducts], products => {
return Object.fromEntries(products.map(p => {
return [
p.id,
p.attributesAsList,
]
}))
})
export const actions = { export const actions = {
...productsState.actions ...productsState.actions
@ -60,8 +169,13 @@ export const actions = {
export const { export const {
createProduct, createProduct,
updateProduct,
deleteProduct, deleteProduct,
changeKey,
updateAttribute,
addAttribute,
deleteAttribute,
updatePrice,
updateDimensions,
} = productsState.actions; } = productsState.actions;
export default productsState.reducer; export default productsState.reducer;

View File

@ -1,5 +1,6 @@
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import convert, { Area, Length } from "convert"; import convert, { Area, Length } from "convert";
import { Transform } from "class-transformer";
export type Id = string; export type Id = string;
@ -14,18 +15,6 @@ export type ProductAttributes = {
currency?: Currency, currency?: Currency,
// [index:string]: any, // [index:string]: any,
} }
export type ProductData = {
id?: Id,
pricePerUnit: number,
measurement: {
unit: string,
value: number,
dimension: number,
},
attributes?: ProductAttributes,
};
export type length_t = { export type length_t = {
l: number, u: Length l: number, u: Length
} }
@ -36,67 +25,76 @@ export type area_t = length_t & {
export type dimensions_t = area_t | length_t; export type dimensions_t = area_t | length_t;
export type ProductData = {
id?: Id,
pricePerUnit: number,
dimensions: dimensions_t,
attributes?: ProductAttributes,
};
export type product_type_t = "area" | "length"; export type product_type_t = "area" | "length";
export const isArea = (d: dimensions_t) => ("width" in d); export const isArea = (d: dimensions_t) => ("width" in d);
export const isLength = (d: dimensions_t) => (!("width" in d)); export const isLength = (d: dimensions_t) => (!("width" in d));
export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length" export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length"
export class Product { export function matchDimensions(d1: dimensions_t, d2: dimensions_t) {
public id: string; if (!
public area?: area_t; (
public length?: length_t; (isArea(d1) && isArea(d2)) ||
public presentUnits: Length; (isLength(d1) && isLength(d2))
)
) {
throw new Error(`Dimension mismatch: ${JSON.stringify(d1)} / ${JSON.stringify(d1)}`);
}
constructor(public pricePerUnit: number, dimensions: dimensions_t, public attributes: ProductAttributes = {},) { return {
this.id = attributes.id || uuid.v4().toString(); l: convert(d1.l, d1.u).to(d2.u),
this.presentUnits = dimensions.u; u: d2.u,
if ("w" in dimensions) { ...(
this.area = { "w" in d1 ?
l: convert(dimensions.l, dimensions.u).to("meter"), { w: convert(d1.w, d1.u).to(d2.u), }
w: convert(dimensions.w, dimensions.u).to("meter"), : {}
u: "meter" )
}
} else {
this.length = {
l: convert(dimensions.l, dimensions.u).to("meter"),
u: "meter"
};
} }
}
export function dimensionArea(d: dimensions_t) {
return "w" in d ? d.w * d.l : 0;
}
export class Product {
public id? : Id;
constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {},
id?: Id,
) {
this.id = id || uuid.v4().toString();
} }
public priceFor(dimensions: dimensions_t): number { public priceFor(dimensions: dimensions_t): number {
if (this.area && "w" in dimensions) { const dim = matchDimensions(dimensions, this.dimensions);
const thisA = this.area.l * this.area.w; return (
const otherA = convert( dim.w ? dimensionArea(dim) / dimensionArea(this.dimensions) * this.pricePerUnit
dimensions.w, : (dim.l / this.dimensions.l) * this.pricePerUnit
dimensions.u )
).to("meter") * convert(
dimensions.l,
dimensions.u
).to("meter");
return (otherA / thisA) * this.pricePerUnit;
} if (this.length) {
const thisL = this.length.l;
const otherL = convert(
dimensions.l,
dimensions.u
).to("meter");
return (otherL / thisL) * this.pricePerUnit;
}
throw new Error(`Invalid dimensions: ${dimensions}`);
} }
get priceDisplay() { get priceDisplay() {
const u = this.presentUnits; return this.pricePerUnit.toLocaleString(undefined, {
if (this.area) { minimumFractionDigits: 2,
const w = Math.round(convert(this.area.w, this.area.u).to(this.presentUnits)); maximumFractionDigits: 2,
const l = Math.round(convert(this.area.l, this.area.u).to(this.presentUnits)); })
return `$ ${this.pricePerUnit} / ${l} x ${w} ${u}`;
} else if (this.length) {
const l = Math.round(convert(this.length.l, this.length.u).to(this.presentUnits));
return `$ ${this.pricePerUnit} per ${l} ${u}`;
} }
get pricePerUnitDisplay() {
const p = this.priceDisplay;
const {l, u} = this.dimensions;
const w = (this.dimensions as area_t).w || null;
const d = w ? `${l}${u} x ${l}${u}` : `${l}${u}`;
return `$${p} per ${d}`
} }
get attributesAsList() { get attributesAsList() {
@ -106,6 +104,30 @@ export class Product {
} }
public removeAttribute(key: string) { public removeAttribute(key: string) {
delete this.attributes[key]; this.attributes = Object.fromEntries(
Object.entries(this.attributes).filter(
([k, v]) => {
k == key;
}
)
);
}
get asObject() : ProductData {
return {
id: this.id,
pricePerUnit: this.pricePerUnit,
dimensions: this.dimensions,
attributes: this.attributes,
}
}
static fromObject({id, pricePerUnit, dimensions, attributes} : ProductData) {
return new Product(
pricePerUnit,
dimensions,
attributes,
id,
)
} }
} }

View File

@ -2,7 +2,7 @@ import { RenderOptions, render } from "@testing-library/react-native";
import { PropsWithChildren, ReactElement } from "react"; import { PropsWithChildren, ReactElement } from "react";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { setupStore, RootState } from "@/app/store"; import { setupStore, RootState } from "@/app/store";
import { Product } from "@/lib/product"; import { Product, ProductData } from "@/lib/product";
export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> { export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>; preloadedState?: Partial<RootState>;
@ -12,7 +12,7 @@ export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
export function renderWithProviders( export function renderWithProviders(
ui: ReactElement, ui: ReactElement,
preloadedState = { preloadedState = {
products: [] as Product [] products: [] as ProductData []
}, },
extendedRenderOptions: ExtendedRenderOptions = {}, extendedRenderOptions: ExtendedRenderOptions = {},
) { ) {

0
lib/util.ts Normal file
View File

View File

@ -20,6 +20,7 @@
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"@testing-library/react-native": "^12.5.1", "@testing-library/react-native": "^12.5.1",
"@types/js-quantities": "^1.6.6", "@types/js-quantities": "^1.6.6",
"class-transformer": "^0.5.1",
"convert": "^5.3.0", "convert": "^5.3.0",
"enheter": "^1.0.27", "enheter": "^1.0.27",
"esm": "link:@types/js-quantities/esm", "esm": "link:@types/js-quantities/esm",
@ -42,10 +43,12 @@
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.1", "react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-select-dropdown": "^4.0.1",
"react-native-uuid": "^2.0.2", "react-native-uuid": "^2.0.2",
"react-native-web": "~0.19.12", "react-native-web": "~0.19.12",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"redux-remember": "^5.1.0", "redux-remember": "^5.1.0",
"rfdc": "^1.4.1",
"safe-units": "^2.0.1", "safe-units": "^2.0.1",
"uuid": "^10.0.0" "uuid": "^10.0.0"
}, },

View File

@ -29,6 +29,9 @@ dependencies:
'@types/js-quantities': '@types/js-quantities':
specifier: ^1.6.6 specifier: ^1.6.6
version: 1.6.6 version: 1.6.6
class-transformer:
specifier: ^0.5.1
version: 0.5.1
convert: convert:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0 version: 5.3.0
@ -95,6 +98,9 @@ dependencies:
react-native-screens: react-native-screens:
specifier: 3.31.1 specifier: 3.31.1
version: 3.31.1(react-native@0.74.2)(react@18.2.0) version: 3.31.1(react-native@0.74.2)(react@18.2.0)
react-native-select-dropdown:
specifier: ^4.0.1
version: 4.0.1
react-native-uuid: react-native-uuid:
specifier: ^2.0.2 specifier: ^2.0.2
version: 2.0.2 version: 2.0.2
@ -107,6 +113,9 @@ dependencies:
redux-remember: redux-remember:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.1.0(redux@5.0.1) version: 5.1.0(redux@5.0.1)
rfdc:
specifier: ^1.4.1
version: 1.4.1
safe-units: safe-units:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1 version: 2.0.1
@ -4187,6 +4196,10 @@ packages:
/cjs-module-lexer@1.3.1: /cjs-module-lexer@1.3.1:
resolution: {integrity: sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==} resolution: {integrity: sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==}
/class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
dev: false
/clean-stack@2.2.0: /clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -8838,6 +8851,10 @@ packages:
warn-once: 0.1.1 warn-once: 0.1.1
dev: false dev: false
/react-native-select-dropdown@4.0.1:
resolution: {integrity: sha512-t4se17kALFcPb9wMbxig5dS1BE3pWRC6HPuFlM0J2Y6yhB1GsLqboy6an6R9rML8pRuGIJIxL29cbwEvPQwKxQ==}
dev: false
/react-native-uuid@2.0.2: /react-native-uuid@2.0.2:
resolution: {integrity: sha512-5ypj/hV58P+6VREdjkW0EudSibsH3WdqDERoHKnD9syFWjF+NfRWWrJb2sa3LIwI5zpzMvUiabs+DX40WHpEMw==} resolution: {integrity: sha512-5ypj/hV58P+6VREdjkW0EudSibsH3WdqDERoHKnD9syFWjF+NfRWWrJb2sa3LIwI5zpzMvUiabs+DX40WHpEMw==}
engines: {node: '>=10.0.0', npm: '>=6.0.0'} engines: {node: '>=10.0.0', npm: '>=6.0.0'}
@ -9219,6 +9236,10 @@ packages:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
/rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
dev: false
/rimraf@2.4.5: /rimraf@2.4.5:
resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==} resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==}
deprecated: Rimraf versions prior to v4 are no longer supported deprecated: Rimraf versions prior to v4 are no longer supported