the type construct of components display. TODO: add area rug calculator.

This commit is contained in:
Jordan 2024-07-31 10:01:45 -07:00
parent 23d957824b
commit dbba262044
16 changed files with 560 additions and 380 deletions

View File

@ -1,21 +1,106 @@
import { Product } from "@/lib/product"; import { Product } from "@/lib/product"
import uuid from "react-native-uuid"
export const products = [ export default [
// Sheet goods // Sheet goods
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/4\"" }), {
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/2\"" }), id: uuid.v4().valueOf(),
new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 3/4\"" }), pricePerUnit: 15,
new Product(5, {l: 4, w : 8, u: "ft"}, { name: "Thin Panel Board" }), dimensions: { l: 4, w: 8, u: "ft" },
new Product(10, {l: 4, w : 8, u: "ft"}, { name: "Sheetrock" }), type: "lumber",
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "OSB / Particle" }), attributes: { name: 'Plywood 1/4"' },
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "MDF" }), },
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "Pegboard" }), {
new Product(5, {l: 3, w : 5, u: "ft"}, { name: "Cement" }), id: uuid.v4().valueOf(),
// trim pricePerUnit: 20,
new Product(1, {l: 0.50, u : "ft"}, { name: "trim <= 3 inches" }), dimensions: { l: 4, w: 8, u: "ft" },
new Product(1, {l: 0.75, u : "ft"}, { name: "trim > 3 inches" }), type: "lumber",
// siding attributes: { name: 'Plywood 1/2"' },
new Product(1, {l: 1, u: "ft"}, {name: "house siding"}), },
new Product(1, {l: 1, u: "ft"}, {name: "metal / shelf bars"}), {
new Product(0.5, {l: 1, u: "ft"}, {name: "gutter spouts"}), id: uuid.v4().valueOf(),
]; pricePerUnit: 25,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: 'Plywood 3/4"' },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 5,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "Thin Panel Board" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 10,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "Sheetrock" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 15,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "OSB / Particle" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 20,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "MDF" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 15,
dimensions: { l: 4, w: 8, u: "ft" },
type: "lumber",
attributes: { name: "Pegboard" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 5,
dimensions: { l: 3, w: 5, u: "ft" },
type: "lumber",
attributes: { name: "Cement" },
},
// trim
{
id: uuid.v4().valueOf(),
pricePerUnit: 1,
dimensions: { l: 0.5, u: "ft" },
type: "lumber",
attributes: { name: "trim <=3 inches" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 1,
dimensions: { l: 0.75, u: "ft" },
type: "lumber",
attributes: { name: "trim > 3 inches" },
},
// siding
{
id: uuid.v4().valueOf(),
pricePerUnit: 1,
dimensions: { l: 1, u: "ft" },
type: "lumber",
attributes: { name: "house siding" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 1,
dimensions: { l: 1, u: "ft" },
type: "lumber",
attributes: { name: "metal / shelf bars" },
},
{
id: uuid.v4().valueOf(),
pricePerUnit: 0.5,
dimensions: { l: 1, u: "ft" },
type: "lumber",
attributes: { name: "gutter spouts" },
},
] as Array<Product>;

View File

@ -4,13 +4,13 @@ import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { TabBarIcon } from '@/components/navigation/TabBarIcon'; import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { products as fixtures } from "@/__fixtures__/initialProducts" import fixtures from "@/__fixtures__/initialProducts"
import { setupStore } from '../store'; 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.map(p => p.asObject), products: fixtures,
units: "ft", units: "ft",
}); });
return ( return (

View File

@ -1,68 +1,87 @@
import { Product } from "@/lib/product"; import { product_type_t } from "@/lib/dimensions";
import { PRODUCT_TYPES, Product } from "@/lib/product";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import React from "react"; import { StyleSheet, TextInput, TouchableHighlight, View } from "react-native";
import { useState } from "react"; import SelectDropdown from "react-native-select-dropdown";
import { StyleSheet, Text, TextInput, TouchableHighlight, View } from "react-native";
export type ProductAttributeChangeFunc = (key: string, newValue: string) => any; export type ProductAttributeChangeFunc = (key: string, newValue: string) => any;
export type ProductAttributeDeleteFunc = (key: string) => any; export type ProductAttributeDeleteFunc = (key: string) => any;
export type ChangeAttributeFunction = (oldKey : string, newKey : string) => any; export type ChangeAttributeFunction = (oldKey: string, newKey: string) => any;
export type ProductTypeChangeFunc = (
key: string,
newProductType: product_type_t
) => any;
export type ProductAttributeProps = { export type ProductAttributeProps = {
attributeKey: string, attributeKey: string;
attributeValue: string, attributeValue: string;
onChangeAttributeKey?: ChangeAttributeFunction, onProductTypeChange?: ProductTypeChangeFunc;
onChangeAttribute?: ProductAttributeChangeFunc, onChangeAttributeKey?: ChangeAttributeFunction;
onDelete?: ProductAttributeChangeFunc, onChangeAttribute?: ProductAttributeChangeFunc;
onDelete?: ProductAttributeChangeFunc;
}; };
export const ProductAttributeEditor = ({ attributeKey, attributeValue, onDelete, onChangeAttributeKey, onChangeAttribute }: ProductAttributeProps) => { const select_product_type_choices = PRODUCT_TYPES.map((p) => [p, p]);
const doChangeKey = (e: any) => { export const ProductAttributeEditor = ({
onChangeAttributeKey && onChangeAttributeKey(attributeKey, e); attributeKey,
} attributeValue,
onDelete,
onChangeAttributeKey,
onChangeAttribute,
}: ProductAttributeProps) => {
const doChangeKey = (e: any) => {
onChangeAttributeKey && onChangeAttributeKey(attributeKey, e);
};
const doChangeValue = (e: any) => { const doChangeValue = (e: any) => {
onChangeAttribute && onChangeAttribute(attributeKey, e); onChangeAttribute && onChangeAttribute(attributeKey, e);
} };
return ( return (
<View> <View>
<View style={styles.productAttributeRow}> <View style={styles.productAttributeRow}>
<TextInput <TextInput
defaultValue={attributeKey} defaultValue={attributeKey}
onChangeText={doChangeKey} onChangeText={doChangeKey}
style={styles.value} style={styles.value}
aria-label="Edit Key" aria-label="Edit Key"
/> />
<TextInput <TextInput
defaultValue={attributeValue} defaultValue={attributeValue}
onChangeText={doChangeValue} onChangeText={doChangeValue}
style={styles.value} style={styles.value}
aria-label="Edit Value" /> aria-label="Edit Value"
<TouchableHighlight />
onPress={() => onDelete && onDelete(attributeKey, attributeValue)} <TouchableHighlight
aria-label="Delete Attribute" onPress={() => onDelete && onDelete(attributeKey, attributeValue)}
style={{ backgroundColor: "darkred", borderRadius: 5, margin: 5, padding: 5, }}> aria-label="Delete Attribute"
<Ionicons name="trash-bin-outline" size={30} color={"white"} /> style={{
</TouchableHighlight> backgroundColor: "darkred",
</View> borderRadius: 5,
</View> margin: 5,
) padding: 5,
} }}
>
<Ionicons name="trash-bin-outline" size={30} color={"white"} />
</TouchableHighlight>
</View>
</View>
);
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
productAttributeRow: { productAttributeRow: {
flexDirection: "row", flexDirection: "row",
}, },
key: { key: {
flex: 1, flex: 1,
}, },
value: { value: {
flex: 1, flex: 1,
borderWidth: 1, borderWidth: 1,
borderColor: "grey", borderColor: "grey",
borderStyle: "solid", borderStyle: "solid",
padding: 10 padding: 10,
} },
}); });

View File

@ -1,5 +1,5 @@
import { Product } from '@/lib/product'; import { Product, productPriceFor } from '@/lib/product';
import { dimensions_t } from "@/lib/dimensions_t"; import { dimensions_t } from "@/lib/dimensions";
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
@ -23,7 +23,7 @@ export default function ProductCalculatorSelector() {
const iv = setInterval(function () { const iv = setInterval(function () {
if (!(activeProduct && measurement)) return; if (!(activeProduct && measurement)) return;
setPrice( setPrice(
activeProduct.priceFor(measurement, percentDamage) productPriceFor(activeProduct, measurement, percentDamage)
) )
}, 50); }, 50);
return function () { return function () {
@ -34,7 +34,7 @@ export default function ProductCalculatorSelector() {
function onMeasurementSet(dimensions: dimensions_t) { function onMeasurementSet(dimensions: dimensions_t) {
setMeasurement(dimensions); setMeasurement(dimensions);
activeProduct && setPrice( activeProduct && setPrice(
activeProduct.priceFor(measurement, percentDamage) productPriceFor(activeProduct, measurement, percentDamage)
) )
} }

View File

@ -1,9 +1,9 @@
import { useAppDispatch, useAppSelector } from "@/app/store" import { useAppDispatch, useAppSelector } from "@/app/store"
import { addAttribute, changeKey, deleteAttribute, deleteProduct, selectProductIds, selectProducts, updateAttribute, updateDimensions, updatePrice, updateProduct } from "@/features/product/productSlice" import { addAttribute, changeKey, deleteAttribute, deleteProduct, selectProductIds, selectProducts, updateAttribute, updateDimensions, updatePrice, updateProduct } from "@/features/product/productSlice"
import { Id, Product } from "@/lib/product"; import { Id, Product } from "@/lib/product";
import { dimensions_t } from "@/lib/dimensions_t";
import { FlatList, SafeAreaView, StyleSheet, Text } from "react-native"; import { FlatList, SafeAreaView, StyleSheet, Text } from "react-native";
import { ProductEditorItem } from "./ProductEditorItem"; import { ProductEditorItem } from "./ProductEditorItem";
import { dimensions_t } from "@/lib/dimensions";
export const ProductEditor = ({}) => { export const ProductEditor = ({}) => {
const products = useAppSelector(selectProducts) as Product []; const products = useAppSelector(selectProducts) as Product [];

View File

@ -1,231 +1,274 @@
import { Id, Product } from "@/lib/product" import { Id, Product, product_type_t } from "@/lib/product";
import { dimensions_t } from "@/lib/dimensions_t"; import { useState } from "react";
import { useState } from "react" import {
import { Button, FlatList, StyleSheet, Text, TextInput, Touchable, TouchableHighlight, View } from "react-native" Button,
FlatList,
StyleSheet,
Text,
TextInput,
Touchable,
TouchableHighlight,
View,
} from "react-native";
import { ProductAttributeEditor } from "./ProductAttributeEditor"; import { ProductAttributeEditor } from "./ProductAttributeEditor";
import { Dropdown } from 'react-native-element-dropdown'; import { Dropdown } from "react-native-element-dropdown";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Length } from "convert"; import { Length } from "convert";
import { dimensions_t } from "@/lib/dimensions";
export type ProductAddedFunc = () => any; export type ProductAddedFunc = () => any;
export type ProductDeletedFunc = (product_id: Id) => any; export type ProductDeletedFunc = (product_id: Id) => any;
export type AttributeAddedFunc = (product_id: Id) => any; export type AttributeAddedFunc = (product_id: Id) => any;
export type AttributeKeyUpdatedFunc = (product_id: Id, oldKey: string, newKey: string) => any; export type AttributeKeyUpdatedFunc = (
export type AttributeUpdatedFunc = (product_id: Id, attribute: string, value: string) => any; 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 AttributeDeletedFunc = (product_id: Id, attribute: string) => any;
export type PriceUpdatedFunc = (product_id: Id, price: number) => any; export type PriceUpdatedFunc = (product_id: Id, price: number) => any;
export type DimensionUpdatedFunc = (product_id: Id, dimension: dimensions_t) => any; export type DimensionUpdatedFunc = (
product_id: Id,
dimension: dimensions_t
) => any;
export type ProductTypeChangedFunc = (
product_id: Id,
product_type: product_type_t
) => any;
export type ProductEditorItemProps = { export type ProductEditorItemProps = {
product: Product, product: Product;
onProductAdded?: ProductAddedFunc, onProductAdded?: ProductAddedFunc;
onProductDeleted?: ProductDeletedFunc, onProductDeleted?: ProductDeletedFunc;
onAttributeAdded?: AttributeAddedFunc, onAttributeAdded?: AttributeAddedFunc;
onAttributeKeyChanged?: AttributeKeyUpdatedFunc, onAttributeKeyChanged?: AttributeKeyUpdatedFunc;
onAttributeUpdated?: AttributeUpdatedFunc, onAttributeUpdated?: AttributeUpdatedFunc;
onAttributeDeleted?: AttributeDeletedFunc, onAttributeDeleted?: AttributeDeletedFunc;
onPriceUpdated?: PriceUpdatedFunc, onPriceUpdated?: PriceUpdatedFunc;
onDimensionsUpdated?: DimensionUpdatedFunc, onDimensionsUpdated?: DimensionUpdatedFunc;
} onProductTypeChanged?: ProductTypeChangedFunc;
};
export const ProductEditorItem = (props: ProductEditorItemProps) => { export const ProductEditorItem = (props: ProductEditorItemProps) => {
const [showAttributes, setShowAttributes] = useState(false);
const product = props.product;
const [showAttributes, setShowAttributes] = useState(false); function onProductTypeChange(id: Id, newProductType: product_type_t) {
const product = props.product; props.onProductTypeChanged &&
props.onProductTypeChanged(product.id as Id, newProductType);
}
function onAttributeChanged(key: string, newValue: string) { function onAttributeChanged(key: string, newValue: string) {
props.onAttributeUpdated && props.onAttributeUpdated(product.id, key, newValue); props.onAttributeUpdated &&
} props.onAttributeUpdated(product.id as Id, key, newValue);
}
function onAttributeKeyChanged(oldKey: string, newKey: string) { function onAttributeKeyChanged(oldKey: string, newKey: string) {
props.onAttributeKeyChanged && props.onAttributeKeyChanged(product.id, oldKey, newKey); props.onAttributeKeyChanged &&
} props.onAttributeKeyChanged(product.id as Id, oldKey, newKey);
}
function onAttributeDelete(key: string) { function onAttributeDelete(key: string) {
props.onAttributeDeleted && props.onAttributeDeleted(product.id, key); props.onAttributeDeleted && props.onAttributeDeleted(product.id as Id, key);
} }
function onPricePerUnitChange(pricePerUnit: string) { function onPricePerUnitChange(pricePerUnit: string) {
props.onPriceUpdated && props.onPriceUpdated(product.id, parseFloat(pricePerUnit) || parseInt(pricePerUnit)); props.onPriceUpdated &&
} props.onPriceUpdated(
product.id as Id,
parseFloat(pricePerUnit) || parseInt(pricePerUnit)
);
}
function onUnitsChanged(newUnits: Length) { function onUnitsChanged(newUnits: Length) {
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, { props.onDimensionsUpdated &&
...(product.dimensions as dimensions_t), props.onDimensionsUpdated(product.id as Id, {
u: newUnits, ...(product.dimensions as dimensions_t),
}) u: newUnits,
} });
}
function onChangeLength(len: string) { function onChangeLength(len: string) {
const l = parseFloat(len) || parseInt(len); const l = parseFloat(len) || parseInt(len);
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, { props.onDimensionsUpdated &&
...(product.dimensions as dimensions_t), props.onDimensionsUpdated(product.id as Id, {
l, ...(product.dimensions as dimensions_t),
}) l,
} });
}
function onChangeWidth(width: string) { function onChangeWidth(width: string) {
const w = width.length == 0 ? null : parseFloat(width) || parseInt(width); const w = width.length == 0 ? null : parseFloat(width) || parseInt(width);
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, { props.onDimensionsUpdated &&
...(product.dimensions as dimensions_t), props.onDimensionsUpdated(product.id as Id, {
...(w ? {w} : {}), ...(product.dimensions as dimensions_t),
}) ...(w ? { w } : {}),
} });
}
function onDeleteProduct() { function onDeleteProduct() {
props.onProductDeleted && props.onProductDeleted(product.id); props.onProductDeleted && props.onProductDeleted(product.id as Id);
} }
const length = new String(product.dimensions.l || product.dimensions.l || "0") as string; const length = new String(
const width = new String(product.dimensions.w || "") as string; product.dimensions.l || product.dimensions.l || "0"
const dimension = product.dimensions.u || product.dimensions.u || "foot"; ) as string;
const width = new String(product.dimensions.w || "") as string;
const dimension = product.dimensions.u || product.dimensions.u || "foot";
return ( return (
<View> <View>
<View style={styles.productListHeader}> <View style={styles.productListHeader}>
<TouchableHighlight <TouchableHighlight
onPress={() => setShowAttributes(!showAttributes)} onPress={() => setShowAttributes(!showAttributes)}
aria-label="Product Item" aria-label="Product Item"
style={styles.productItemName} style={styles.productItemName}
> >
<Text style={styles.productNameText}>{product.attributes.name || `Product ${product.id}`}</Text> {product.attributes && (
</TouchableHighlight> <Text style={styles.productNameText}>
<TouchableHighlight {product.attributes.name || `Product ${product.id}`}
onPress={() => onDeleteProduct()} </Text>
aria-label="delete product" )}
style={styles.deleteProductHighlight} </TouchableHighlight>
> <TouchableHighlight
<Ionicons onPress={() => onDeleteProduct()}
style={styles.deleteProductButton} aria-label="delete product"
name="trash-outline" style={styles.deleteProductHighlight}
/> >
</TouchableHighlight> <Ionicons style={styles.deleteProductButton} name="trash-outline" />
</View> </TouchableHighlight>
{showAttributes && </View>
( {showAttributes && (
<View style={styles.detailsWrapper}> <View style={styles.detailsWrapper}>
<View style={styles.priceSpecWrapper}> <View style={styles.priceSpecWrapper}>
<Text style={styles.priceLabel}>$</Text> <Text style={styles.priceLabel}>$</Text>
<TextInput inputMode="decimal" <TextInput
defaultValue={new String(product.pricePerUnit) as string} inputMode="decimal"
aria-label="price per unit" defaultValue={new String(product.pricePerUnit).valueOf()}
onChangeText={onPricePerUnitChange} aria-label="price per unit"
style={styles.priceInput} onChangeText={onPricePerUnitChange}
/> style={styles.priceInput}
<Text style={styles.per}>per</Text> />
<Dropdown <Text style={styles.per}>per</Text>
data={[ <Dropdown
{label: "feet", value: "ft"}, data={[
{label: "inches", value: "in"}, { label: "feet", value: "ft" },
]} { label: "inches", value: "in" },
style={styles.unitsSelect} ]}
mode="modal" style={styles.unitsSelect}
labelField="label" mode="modal"
valueField="value" labelField="label"
value={product.dimensions.u || "ft"} valueField="value"
onChange={(item) => onUnitsChanged(item.value as Length)} value={product.dimensions.u || "ft"}
/> onChange={(item) => onUnitsChanged(item.value as Length)}
<TextInput />
inputMode="decimal" <TextInput
defaultValue={length} inputMode="decimal"
onChangeText={onChangeLength} defaultValue={length}
style={styles.lengthInput} onChangeText={onChangeLength}
aria-label="length" style={styles.lengthInput}
/> aria-label="length"
<Text style={{flex: 1,}}>x</Text> />
<TextInput <Text style={{ flex: 1 }}>x</Text>
inputMode="decimal" <TextInput
defaultValue={width} inputMode="decimal"
onChangeText={onChangeWidth} defaultValue={width}
style={styles.widthInput} onChangeText={onChangeWidth}
aria-label="width" style={styles.widthInput}
/> aria-label="width"
</View> />
<Button title="+ Add Attribute" onPress={() => props.onAttributeAdded && props.onAttributeAdded(product.id)} /> </View>
<FlatList <Button
style={styles.productAttributesList} title="+ Add Attribute"
data={Object.entries(product.attributes)} onPress={() =>
renderItem={({ item }) => ( props.onAttributeAdded && props.onAttributeAdded(product.id as Id)
<ProductAttributeEditor
attributeKey={item[0] || "some key"}
attributeValue={item[1]}
onChangeAttributeKey={onAttributeKeyChanged}
onChangeAttribute={onAttributeChanged}
onDelete={onAttributeDelete}
/>
)}
keyExtractor={(item, i) => `${product.id}-${i}`}
/>
</View>
)
} }
/>
{product.attributes && (
<FlatList
style={styles.productAttributesList}
data={Object.entries(product.attributes)}
renderItem={({ item }) => (
<ProductAttributeEditor
onProductTypeChange={onProductTypeChange}
attributeKey={item[0] || "some key"}
attributeValue={item[1]}
onChangeAttributeKey={onAttributeKeyChanged}
onChangeAttribute={onAttributeChanged}
onDelete={onAttributeDelete}
/>
)}
keyExtractor={(item, i) => `${product.id}-${i}`}
/>
)}
</View> </View>
) )}
} </View>
);
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
deleteProductHighlight: { deleteProductHighlight: {
padding: 5,
padding: 5, borderWidth: 1,
borderWidth: 1, },
}, deleteProductButton: {
deleteProductButton: { fontSize: 20,
fontSize: 20, },
}, detailsWrapper: {},
detailsWrapper: { priceSpecWrapper: {
flexDirection: "row",
}, },
priceSpecWrapper: { priceLabel: {},
flexDirection: "row", priceInput: {
}, flex: 1,
priceLabel: { borderWidth: 2,
}, borderColor: "lightgrey",
priceInput: { borderStyle: "solid",
flex: 1, },
borderWidth: 2, per: {
borderColor: "lightgrey", padding: 5,
borderStyle: "solid", },
}, unitsLabel: {},
per: { unitsSelect: {
padding: 5, flex: 1,
}, padding: 5,
unitsLabel: { },
}, lengthInput: {
unitsSelect: { flex: 1,
flex: 1, borderWidth: 2,
padding: 5, borderColor: "lightgrey",
}, borderStyle: "solid",
lengthInput: { },
flex: 1, widthInput: {
borderWidth: 2, flex: 1,
borderColor: "lightgrey", borderWidth: 2,
borderStyle: "solid", borderColor: "lightgrey",
}, borderStyle: "solid",
widthInput: { },
flex: 1, productListHeader: {
borderWidth: 2, flexDirection: "row",
borderColor: "lightgrey", padding: 5,
borderStyle: "solid", },
}, productNameText: {
productListHeader: { paddingLeft: 10,
flexDirection: "row", paddingRight: 10,
padding: 5, },
}, productItemName: {
productNameText: { flex: 1,
paddingLeft: 10, backgroundColor: "lightgrey",
paddingRight: 10, padding: 4,
}, margin: 4,
productItemName: { },
flex: 1, productAttributesList: {
backgroundColor: "lightgrey", margin: 10,
padding: 4, padding: 10,
margin: 4, borderWidth: 1,
}, borderStyle: "solid",
productAttributesList: { borderColor: "black",
margin: 10, },
padding: 10, });
borderWidth: 1,
borderStyle: "solid",
borderColor: "black",
},
})

View File

@ -1,19 +1,22 @@
import { FlatList, ScrollView, StyleSheet, Text, TouchableHighlight } from "react-native"; import { ScrollView, StyleSheet } from "react-native";
import { ProductTile } from "./ProductTile"; import { ProductTile } from "./ProductTile";
import { Id, Product } from "@/lib/product"; import { Product } from "@/lib/product";
import { Key, useEffect, useState } from "react"; import { useState } from "react";
import { useSelector } from "react-redux";
import { selectProducts } from "@/features/product/productSlice"; import { selectProducts } from "@/features/product/productSlice";
import { useAppSelector } from "@/app/store"; import { useAppSelector } from "@/app/store";
export type ProductSelectionProps = { export type ProductSelectionProps = {
onProductSelected?: (product: Product) => any; onProductSelected?: (product: Product) => any;
} };
export default function ProductList({ onProductSelected }: ProductSelectionProps) {
export default function ProductList({
onProductSelected,
}: ProductSelectionProps) {
const [activeProduct, setActiveProduct] = useState(null as null | Product); const [activeProduct, setActiveProduct] = useState(null as null | Product);
const products = useAppSelector(selectProducts).filter(p => (!!p.dimensions)); const products = useAppSelector(selectProducts).filter(p => !!p).filter((p) => {
console.dir(p);
return !!p.dimensions;
});
function doOnProductSelected(product: Product) { function doOnProductSelected(product: Product) {
setActiveProduct(product); setActiveProduct(product);
@ -21,8 +24,8 @@ export default function ProductList({ onProductSelected }: ProductSelectionProps
} }
return ( return (
<ScrollView scrollToOverflowEnabled={true}> <ScrollView scrollToOverflowEnabled={true} aria-label="product list">
{products.map(product => { {products.map((product) => {
return ( return (
<ProductTile <ProductTile
product={product} product={product}
@ -33,14 +36,12 @@ export default function ProductList({ onProductSelected }: ProductSelectionProps
); );
})} })}
</ScrollView> </ScrollView>
) );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
productSelectorFlatList: { productSelectorFlatList: {
padding: 10, padding: 10,
margin: 10, margin: 10,
}, },
});
})

View File

@ -1,4 +1,4 @@
import { Product } from "@/lib/product" import { Product, priceDisplay, pricePerUnitDisplay } from "@/lib/product"
import { ImageBackground, StyleProp, StyleSheet, Text, TouchableHighlight, View, ViewStyle } from "react-native"; import { ImageBackground, StyleProp, StyleSheet, Text, TouchableHighlight, View, ViewStyle } from "react-native";
import { AnimatedStyle } from "react-native-reanimated"; import { AnimatedStyle } from "react-native-reanimated";
@ -23,12 +23,14 @@ const FALLBACK_IMAGE = "";
export function ProductTile ({product, onProductSelected, isActive} : ProductTileProps) { export function ProductTile ({product, onProductSelected, isActive} : ProductTileProps) {
const k = isActive ? "active" : "default"; const k = isActive ? "active" : "default";
const priceDisplay = pricePerUnitDisplay(product);
return ( return (
<TouchableHighlight <TouchableHighlight
style={styles[k].highlight} style={styles[k].highlight}
onPress={() => onProductSelected && onProductSelected(product)}> onPress={() => onProductSelected && onProductSelected(product)}>
<Text style={styles[k].text}>{product.attributes.name || `Product ${product.id}`} ({product.pricePerUnitDisplay})</Text> <Text style={styles[k].text}>{product.attributes?.name || `Product ${product.id}`} ({priceDisplay})</Text>
</TouchableHighlight> </TouchableHighlight>
); );
} }

View File

@ -1,25 +1,16 @@
import { Product } from "@/lib/product" import { LumberProduct, Product } from "@/lib/product"
import {ProductAttributeEditor} from "../ProductAttributeEditor" import {ProductAttributeEditor} from "../ProductAttributeEditor"
import { area } from "enheter"
import { fireEvent, render, screen } from '@testing-library/react-native'; import { fireEvent, render, screen } from '@testing-library/react-native';
import React from "react";
import { emitTypingEvents } from "@testing-library/react-native/build/user-event/type/type";
describe("Product editor tests", () => { describe("Product editor tests", () => {
const productName = "Fun Product"; const productName = "Fun Product";
it("Product attributes can be deleted", async () => { it("Product attributes can be deleted", async () => {
const product = new Product(
100,
{l: 100, u: "foot"},
{"name" : productName}
);
const onChange = jest.fn(); const onChange = jest.fn();
const onDelete = jest.fn(); const onDelete = jest.fn();
render( render(
<ProductAttributeEditor <ProductAttributeEditor
attributeKey="name" attributeKey="name"
attributeValue="product" attributeValue="product"
product={product}
onChangeAttribute={onChange} onChangeAttribute={onChange}
onDelete={onDelete} onDelete={onDelete}
/>); />);
@ -28,11 +19,14 @@ 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 product = new Product( const product : Product = {
100, pricePerUnit: 10,
{l: 100, u: "foot"}, dimensions: {
{"name" : productName} l: 40,
); u: "ft",
},
type: "lumber",
}
const onChange = jest.fn(); const onChange = jest.fn();
const onDelete = jest.fn(); const onDelete = jest.fn();
const onKeyChange = jest.fn(); const onKeyChange = jest.fn();

View File

@ -2,50 +2,56 @@ import { renderWithProviders } from "@/lib/rendering";
import { ProductEditor } from "@/components/ProductEditor"; import { ProductEditor } from "@/components/ProductEditor";
import { act, fireEvent, screen } from "@testing-library/react-native"; import { act, fireEvent, screen } from "@testing-library/react-native";
import { selectProducts } from "@/features/product/productSlice"; import { selectProducts } from "@/features/product/productSlice";
import { Product } from "@/lib/product"; import { LumberProduct, Product } from "@/lib/product";
describe("ProductEditor", () => { describe("ProductEditor", () => {
const productName = "Flooring" const productName = "Flooring";
const mockProduct = new Product( const mockProduct: LumberProduct = {
25, attributes: {
{ l: 4, w: 8, u: "foot" }, name: productName,
{ name: productName }, },
) pricePerUnit: 10,
it("renders correctly", async () => { dimensions: {
const { store } = renderWithProviders(<ProductEditor />, { l: 40,
products: [ u: "ft",
mockProduct.asObject, },
], type: "lumber",
}); };
it("renders correctly", async () => {
const state1 = store.getState(); const { store } = renderWithProviders(<ProductEditor />, {
products: [mockProduct],
let products = selectProducts(state1);
expect(products).toHaveLength(1);
// Check if the product names are rendered
expect(screen.getByText(products[0].attributes.name as string)).toBeTruthy();
// Start to edit a product
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);
}); });
const state1 = store.getState();
let products = selectProducts(state1);
expect(products).toHaveLength(1);
// Check if the product names are rendered
expect(
screen.getByText(products[0].attributes.name as string)
).toBeTruthy();
// Start to edit a product
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

@ -4,14 +4,22 @@ 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'; import { renderWithProviders } from '@/lib/rendering';
import { area_t } from '@/lib/dimensions';
describe('ProductEditorItem', () => { describe('ProductEditorItem', () => {
const productName = "Product 1"; const productName = "Product 1";
const mockProduct = new Product( const mockProduct : Product = {
25, type: "area_rug",
{l: 4, u: 'feet'}, dimensions: {
{"name": productName}, l: 1,
) w: 1,
u: "feet",
},
pricePerUnit: 0.75,
attributes: {
name: productName,
}
}
const onAttributeAdded = jest.fn(); const onAttributeAdded = jest.fn();
const mockOnProductDeleted = jest.fn(); const mockOnProductDeleted = jest.fn();
@ -56,7 +64,7 @@ describe('ProductEditorItem', () => {
} }
); );
fireEvent.press(screen.getByText("Product 1")); fireEvent.press(screen.getByText("Product 1"));
expect(screen.getByLabelText("units")).toBeTruthy(); // expect(screen.getByLabelText("Units")).toBeTruthy();
expect(screen.getByLabelText("Edit Key")).toBeTruthy(); expect(screen.getByLabelText("Edit Key")).toBeTruthy();
expect(screen.getAllByLabelText("Edit Value").length).toEqual(1); expect(screen.getAllByLabelText("Edit Value").length).toEqual(1);

View File

@ -0,0 +1,27 @@
import React from 'react';
import { renderWithProviders } from '@/lib/rendering';
import { Product } from '@/lib/product';
import ProductList from '@/components/ProductList';
import initialProducts from '@/__fixtures__/initialProducts';
import { screen } from '@testing-library/react-native';
describe('ProductList', () => {
it('renders without crashing', () => {
const { getByTestId } = renderWithProviders(<ProductList />, {
products: initialProducts,
});
expect(screen.getByLabelText('product list')).toBeTruthy();
});
it('renders products correctly', () => {
const mockProduct = initialProducts[0];
const { getByText } = renderWithProviders(<ProductList />, {
products: [mockProduct],
});
expect(getByText(mockProduct.attributes.name)).toBeTruthy();
expect(getByText(`$${mockProduct.pricePerUnit}`)).toBeTruthy();
});
});

View File

@ -151,7 +151,7 @@ const productsState = createSlice({
} }
}); });
export const selectProductsDatas = (state: RootState) => { export const selectProducts = (state: RootState) => {
return state.products; return state.products;
} }
@ -159,10 +159,6 @@ export const selectUnits = (state : RootState) => {
return (state.units || "ft") as Length; return (state.units || "ft") as Length;
} }
export const selectProducts = createSelector([selectProductsDatas], productsData => {
return productsData.map(d => Product.fromObject(d));
})
export const selectProductIds = createSelector([selectProducts], products => { export const selectProductIds = createSelector([selectProducts], products => {
return products.map(p => p.id); return products.map(p => p.id);
}) })

View File

@ -1,4 +1,3 @@
import { length } from "enheter";
import { Product } from "../product"; import { Product } from "../product";
describe("Product tests", () => { describe("Product tests", () => {
@ -20,11 +19,4 @@ describe("Product tests", () => {
const comparison = standard.priceFor({l : 24, u: "inch"}); const comparison = standard.priceFor({l : 24, u: "inch"});
expect(comparison).toBeCloseTo(20, 4); expect(comparison).toBeCloseTo(20, 4);
}); });
it("Can convert to/from object", () => {
const standard = new Product(10, {l: 1, u : "feet"});
const obj = standard.asObject;
const back = Product.fromObject(obj);
expect(back).toEqual(standard);
})
}); });

View File

@ -11,7 +11,6 @@ export type area_t = length_t & {
export type dimensions_t = area_t | length_t; export type dimensions_t = area_t | length_t;
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));

View File

@ -1,6 +1,7 @@
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import { dimensions_t, area_t } from "./dimensions"; import { dimensions_t, area_t } from "./dimensions";
import { matchDimensions } from "./dimensions"; import { matchDimensions } from "./dimensions";
import { Area, Length, Unit } from "convert";
export type Id = string; export type Id = string;
@ -15,11 +16,18 @@ export type ProductAttributes = {
currency?: Currency, currency?: Currency,
[index:string]: any, [index:string]: any,
} }
export function dimensionArea(d: dimensions_t) { export function dimensionArea(d: dimensions_t) {
return "w" in d ? d.w * d.l : 0; return "w" in d ? d.w * d.l : 0;
} }
export type product_type_t = "lumber" | "area_rug" export const PRODUCT_TYPES = [
"lumber",
"area_rug",
] as const;
export type product_type_t = typeof PRODUCT_TYPES[number];
export type Product = { export type Product = {
id?: Id; id?: Id;