change product to dump to object before storing in redix. TODO: solve dimensions issue.
This commit is contained in:
parent
de0167e9e5
commit
408a996fe7
@ -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}>
|
||||||
|
@ -23,17 +23,23 @@ 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 };
|
||||||
const p = activeProduct.priceFor(d);
|
try {
|
||||||
console.log("set price %s", p);
|
const p = activeProduct.priceFor(d);
|
||||||
const s = p.toLocaleString(undefined, {
|
console.log("set price %s", p);
|
||||||
minimumFractionDigits: 2,
|
const s = p.toLocaleString(undefined, {
|
||||||
maximumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
})
|
maximumFractionDigits: 2,
|
||||||
setPrice(s == "NaN" ? "0.00" : s);
|
})
|
||||||
}, 10);
|
setPrice(s == "NaN" ? "0.00" : s);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(activeProduct);
|
||||||
|
console.error(err)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
return function () {
|
return function () {
|
||||||
clearInterval(iv);
|
clearInterval(iv);
|
||||||
}
|
}
|
||||||
@ -67,13 +73,13 @@ export default function HomeScreen() {
|
|||||||
<Text style={styles.unitHints}>{units}</Text>
|
<Text style={styles.unitHints}>{units}</Text>
|
||||||
</View>)
|
</View>)
|
||||||
}
|
}
|
||||||
|
<View style={styles.unitSelector}>
|
||||||
|
<Button title="in" onPress={() => setUnits("in")} color={units === "in" ? "gray" : "blue"} />
|
||||||
|
<Button title="ft" onPress={() => setUnits("ft")} color={units === "ft" ? "gray" : "blue"} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (<Text>Please choose a product</Text>)}
|
) : (<Text>Please choose a product</Text>)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.unitSelector}>
|
|
||||||
<Button title="in" onPress={() => setUnits("in")} color={units === "in" ? "gray" : "blue"} />
|
|
||||||
<Button title="ft" onPress={() => setUnits("ft")} color={units === "ft" ? "gray" : "blue"} />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={products}
|
data={products}
|
||||||
|
@ -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),
|
||||||
|
@ -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"} />
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 &&
|
||||||
(
|
(
|
||||||
<FlatList
|
<View>
|
||||||
style={styles.productAttributesList}
|
<View style={styles.priceSpecWrapper}>
|
||||||
data={product.attributesAsList}
|
<Text style={styles.priceLabel}></Text>
|
||||||
renderItem={({ item }) => (
|
<TextInput inputMode="decimal"
|
||||||
<ProductAttributeEditor
|
defaultValue={new String(product.pricePerUnit) as string}
|
||||||
product={product}
|
aria-label="price per unit"
|
||||||
attributeKey={item.key || "some key"}
|
onChangeText={onPricePerUnitChange}
|
||||||
attributeValue={item.value}
|
style={styles.priceInput}
|
||||||
onChange={onAttributeChanged}
|
|
||||||
onDelete={onAttributeDelete}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<Text style={styles.per}>per</Text>
|
||||||
keyExtractor={(item) => `${product.id}-${item.key}`}
|
<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
|
||||||
|
style={styles.productAttributesList}
|
||||||
|
data={Object.entries(product.attributes)}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ProductAttributeEditor
|
||||||
|
attributeKey={item[0] || "some key"}
|
||||||
|
attributeValue={item[1]}
|
||||||
|
onChangeAttributeKey={onAttributeKeyChanged}
|
||||||
|
onChangeAttribute={onAttributeChanged}
|
||||||
|
onDelete={onAttributeDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
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,
|
||||||
|
@ -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();
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 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;
|
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;
|
||||||
|
144
lib/product.ts
144
lib/product.ts
@ -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"),
|
export function dimensionArea(d: dimensions_t) {
|
||||||
u: "meter"
|
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));
|
get pricePerUnitDisplay() {
|
||||||
return `$ ${this.pricePerUnit} per ${l} ${u}`;
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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
0
lib/util.ts
Normal 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"
|
||||||
},
|
},
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user