Compare commits
No commits in common. "466e005e4eaf5b3022a55f5d82e7132b243c0feb" and "93c0c25eb51670700600d74169051ede0f770613" have entirely different histories.
466e005e4e
...
93c0c25eb5
5
.gitignore
vendored
5
.gitignore
vendored
@ -17,7 +17,4 @@ web-build/
|
|||||||
# The following patterns were generated by expo-cli
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
# @end expo-cliandroid
|
# @end expo-cli
|
||||||
android
|
|
||||||
builds
|
|
||||||
.env
|
|
21
README.md
21
README.md
@ -1,23 +1,4 @@
|
|||||||
# PliWould - Measure And Price Sheet Good Merchandise
|
# Welcome to your Expo app 👋
|
||||||
|
|
||||||
Working behind the register at Habitat for Humanity ReStore I found sheet goods often came in partials.
|
|
||||||
|
|
||||||
As a cashier, I would often have to determine the correct price for merchandise based on the area or length
|
|
||||||
of products.
|
|
||||||
|
|
||||||
Not being skilled in math, I found this process extremely stressful and would often get stuck and call over
|
|
||||||
a manager to assist. Instead, I took it upon myself to find a solution.
|
|
||||||
|
|
||||||
Hence, I created PliWould, an app that determines the cost of a product based on its dimensions.
|
|
||||||
|
|
||||||
By default my store's prices are in the database. However, the prices are editable and you can add or remove
|
|
||||||
products as needed.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
[[ TODO ]]
|
|
||||||
|
|
||||||
# Development Docs
|
|
||||||
|
|
||||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||||
|
|
||||||
|
@ -2,20 +2,11 @@ import { Product } from "@/lib/product";
|
|||||||
|
|
||||||
export const products = [
|
export const products = [
|
||||||
// Sheet goods
|
// Sheet goods
|
||||||
new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/4\"" }),
|
new Product(25, {l: 4, w : 8, u: "feet"}, { name: "Plywood" }),
|
||||||
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/2\"" }),
|
new Product(35, {l: 4, w : 8, u: "feet"}, { name: "MDF" }),
|
||||||
new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 3/4\"" }),
|
new Product(40, {l: 4, w : 8, u: "feet"}, { name: "OSB" }),
|
||||||
new Product(5, {l: 4, w : 8, u: "ft"}, { name: "Thin Panel Board" }),
|
new Product(45, {l: 4, w : 8, u: "feet"}, { name: "Sheetrock" }),
|
||||||
new Product(10, {l: 4, w : 8, u: "ft"}, { name: "Sheetrock" }),
|
// Beams and trim
|
||||||
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "OSB / Particle" }),
|
new Product(1, {l: 0.50, u : "feet"}, { name: "trim 3 inches" }),
|
||||||
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "MDF" }),
|
new Product(1, {l: 0.75, u : "feet"}, { name: "trim 3 inches" }),
|
||||||
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "Pegboard" }),
|
|
||||||
new Product(5, {l: 3, w : 5, u: "ft"}, { name: "Cement" }),
|
|
||||||
// trim
|
|
||||||
new Product(1, {l: 0.50, u : "ft"}, { name: "trim <= 3 inches" }),
|
|
||||||
new Product(1, {l: 0.75, u : "ft"}, { name: "trim > 3 inches" }),
|
|
||||||
// siding
|
|
||||||
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"}),
|
|
||||||
];
|
];
|
11
app.json
11
app.json
@ -19,8 +19,7 @@
|
|||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
}
|
||||||
"package": "tech.damngood.PliWould"
|
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
@ -32,14 +31,6 @@
|
|||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"router": {
|
|
||||||
"origin": false
|
|
||||||
},
|
|
||||||
"eas": {
|
|
||||||
"projectId": "bf9125c3-72d0-42a7-9480-74c4717e7ed3"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,43 +1,37 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { Colors } from '@/constants/Colors';
|
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 { products as fixtures } from "@/__fixtures__/initialProducts"
|
|
||||||
import { setupStore } from '../store';
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const store = setupStore({
|
|
||||||
products: fixtures.map(p => p.asObject)
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Tabs
|
||||||
<Tabs
|
screenOptions={{
|
||||||
screenOptions={{
|
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
headerShown: false,
|
||||||
headerShown: false,
|
}}>
|
||||||
}}>
|
<Tabs.Screen
|
||||||
<Tabs.Screen
|
name="index"
|
||||||
name="index"
|
options={{
|
||||||
options={{
|
title: 'Conversion',
|
||||||
title: 'Home Screen',
|
tabBarIcon: ({ color, focused }) => (
|
||||||
tabBarIcon: ({ color, focused }) => (
|
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
|
||||||
<TabBarIcon name={focused ? 'scale' : 'scale-outline'} color={color} />
|
),
|
||||||
),
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Tabs.Screen
|
||||||
<Tabs.Screen
|
name="product-editor"
|
||||||
name="product-editor"
|
options={{
|
||||||
options={{
|
title: 'Products',
|
||||||
title: 'Products',
|
tabBarIcon: ({ color, focused }) => (
|
||||||
tabBarIcon: ({ color, focused }) => (
|
<TabBarIcon name={focused ? 'recording' : 'recording-outline'} color={color} />
|
||||||
<TabBarIcon name={focused ? 'list' : 'list-outline'} color={color} />
|
),
|
||||||
),
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</Tabs>
|
||||||
</Tabs>
|
|
||||||
</Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,50 @@
|
|||||||
import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
|
import { Image, StyleSheet, Platform, ImageBackground } from 'react-native';
|
||||||
import { SafeAreaView, Text, View } from 'react-native';
|
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { MeasurementInput } from '@/components/LengthInput';
|
||||||
|
import { setupStore, useAppDispatch } from '../store';
|
||||||
|
import { selectProducts } from '@/features/product/productSlice';
|
||||||
|
import { Product } from '@/lib/product';
|
||||||
|
import { ProductTile } from '@/components/ProductTile';
|
||||||
|
import { Measure, area, length } from 'enheter';
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
|
||||||
|
const products = useAppDispatch(selectProducts);
|
||||||
|
|
||||||
|
function calculatePrice() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectProduct = (product : Product) => {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export default function Convert () {
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<SafeAreaView>
|
||||||
<ProductCalculatorSelector />
|
<MeasurementInput onMeasurementSet={calculatePrice} />
|
||||||
</View>
|
{products.map((product) => {
|
||||||
)
|
<ProductTile product={product} onProductSelected={selectProduct} />
|
||||||
|
})}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
titleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
stepContainer: {
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
reactLogo: {
|
||||||
|
height: 178,
|
||||||
|
width: 290,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
import { Image, StyleSheet, Platform, ImageBackground } from 'react-native';
|
import { Image, StyleSheet, Platform, ImageBackground } from 'react-native';
|
||||||
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { ProductEditor } from '@/components/ProductEditor';
|
import { MeasurementInput } from '@/components/LengthInput';
|
||||||
|
import { setupStore, useAppDispatch } from '../store';
|
||||||
|
import { selectProducts } from '@/features/product/productSlice';
|
||||||
|
import { Product } from '@/lib/product';
|
||||||
|
import { ProductTile } from '@/components/ProductTyle';
|
||||||
|
import { Measure, area, length } from 'enheter';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
|
|
||||||
|
@ -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, ProductData, } from "@/lib/product";
|
import { Product, } 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 ProductData[],
|
products: [] as Product[],
|
||||||
}) {
|
}) {
|
||||||
return configureStore({
|
return configureStore({
|
||||||
reducer: rememberReducer(reducers),
|
reducer: rememberReducer(reducers),
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 72 KiB |
Binary file not shown.
Before Width: | Height: | Size: 292 KiB |
@ -1,61 +0,0 @@
|
|||||||
import { MeasurementInput } from "./MeasurementInput";
|
|
||||||
import { area_t, dimensions_t } from "@/lib/product";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { StyleSheet, View } from "react-native";
|
|
||||||
|
|
||||||
export type AreaInputProps = {
|
|
||||||
onMeasurementSet?: (area : dimensions_t) => any,
|
|
||||||
defaultValue?: area_t,
|
|
||||||
lengthLabel?: string,
|
|
||||||
widthLabel?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultValue} : AreaInputProps) {
|
|
||||||
|
|
||||||
defaultValue = defaultValue || {l: 0, w: 0, u: "ft"}
|
|
||||||
|
|
||||||
const [area, setArea] = useState(defaultValue)
|
|
||||||
|
|
||||||
function doOnLengthSet(measurement : dimensions_t) {
|
|
||||||
setArea({
|
|
||||||
...area,
|
|
||||||
l: measurement.l
|
|
||||||
});
|
|
||||||
onMeasurementSet && onMeasurementSet({
|
|
||||||
...area,
|
|
||||||
l: measurement.l
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function doOnWidthSet(measurement : dimensions_t) {
|
|
||||||
setArea({
|
|
||||||
...area,
|
|
||||||
w: measurement.l
|
|
||||||
});
|
|
||||||
onMeasurementSet && onMeasurementSet({
|
|
||||||
...area,
|
|
||||||
w: measurement.l
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.areaInputWrapper}>
|
|
||||||
<MeasurementInput
|
|
||||||
defaultValue={{l: area.l, u: area.u}}
|
|
||||||
onValueSet={doOnLengthSet}
|
|
||||||
label={lengthLabel}
|
|
||||||
/>
|
|
||||||
<MeasurementInput
|
|
||||||
defaultValue={{l: area.w, u: area.u}}
|
|
||||||
onValueSet={doOnWidthSet}
|
|
||||||
label={widthLabel}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
areaInputWrapper: {
|
|
||||||
flexDirection: "row"
|
|
||||||
}
|
|
||||||
})
|
|
86
components/LengthInput.tsx
Normal file
86
components/LengthInput.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { Measure, Unit, length as en_length, area as en_area } from "enheter";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export type t_length_unit = "foot" | "inch"
|
||||||
|
export type mode = "length" | "area"
|
||||||
|
|
||||||
|
export type LengthInputProps = {
|
||||||
|
onMeasurementSet?: (length: Measure<"length" | "area">) => any,
|
||||||
|
isArea?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeasurementInput(props: LengthInputProps) {
|
||||||
|
|
||||||
|
const [length, setLength] = useState(null as null | number);
|
||||||
|
const [width, setWidth] = useState(null as null | number);
|
||||||
|
const [unit, setUnit] = useState("foot" as t_length_unit);
|
||||||
|
|
||||||
|
function doSetLength(text: string) {
|
||||||
|
const value = parseFloat(text);
|
||||||
|
setLength(value);
|
||||||
|
if (!props.isArea) {
|
||||||
|
const len = en_length(unit, value)
|
||||||
|
props.onMeasurementSet && props.onMeasurementSet(len)
|
||||||
|
} else {
|
||||||
|
const en_unit = unit == "foot" ? "squareFoot" : "squareInch"
|
||||||
|
const ar = en_area(en_unit, value);
|
||||||
|
props.onMeasurementSet && props.onMeasurementSet(ar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSetWidth(text: string) {
|
||||||
|
const value = parseFloat(text);
|
||||||
|
setLength(value);
|
||||||
|
const len = en_length(unit, value)
|
||||||
|
props.onMeasurementSet && props.onMeasurementSet(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView>
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="number-pad"
|
||||||
|
onTouchEnd={() => setLength(null)}
|
||||||
|
value={length?.toString() || ""}
|
||||||
|
onChangeText={doSetLength}
|
||||||
|
style={styles.textInput}
|
||||||
|
/>
|
||||||
|
{props.isArea &&
|
||||||
|
(<TextInput
|
||||||
|
keyboardType="number-pad"
|
||||||
|
onTouchEnd={() => setWidth(null)}
|
||||||
|
value={length?.toString() || ""}
|
||||||
|
onChangeText={doSetWidth}
|
||||||
|
style={styles.textInput}
|
||||||
|
/>)
|
||||||
|
}
|
||||||
|
<Text style={styles.valueHint}>{unit == "foot" ? "ft" : "in"}</Text>
|
||||||
|
<Button
|
||||||
|
title="Ft"
|
||||||
|
onPress={() => setUnit("foot")}
|
||||||
|
color={unit === "foot" ? "blue" : "gray"}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="In"
|
||||||
|
onPress={() => setUnit("inch")}
|
||||||
|
color={unit === "inch" ? "blue" : "gray"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
textInput: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
inputRow: {
|
||||||
|
flexDirection: "row"
|
||||||
|
},
|
||||||
|
valueHint: {
|
||||||
|
color: "grey",
|
||||||
|
margin: 5,
|
||||||
|
}
|
||||||
|
})
|
@ -1,62 +0,0 @@
|
|||||||
import { dimensions_t, length_t } from "@/lib/product";
|
|
||||||
import { Length } from "convert";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
|
||||||
|
|
||||||
export type t_length_unit = "foot" | "inch"
|
|
||||||
|
|
||||||
export type MeasurementInputProps = {
|
|
||||||
onValueSet?: (d: dimensions_t) => any,
|
|
||||||
defaultValue: length_t;
|
|
||||||
label?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementInputProps) {
|
|
||||||
|
|
||||||
const [mValue, setMValue] = useState(defaultValue)
|
|
||||||
const defValue = Number.isNaN(defaultValue.l) ? 0 : defaultValue.l
|
|
||||||
|
|
||||||
function doOnValueSet(value : string) {
|
|
||||||
setMValue(mValue);
|
|
||||||
const iVal = parseFloat(value) || parseInt(value);
|
|
||||||
onValueSet && onValueSet({
|
|
||||||
...defaultValue,
|
|
||||||
l: iVal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const sDefValue = new String(defValue).valueOf()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.inputWrapper}>
|
|
||||||
<TextInput
|
|
||||||
clearTextOnFocus={true}
|
|
||||||
defaultValue={sDefValue}
|
|
||||||
onChangeText={doOnValueSet}
|
|
||||||
inputMode='decimal'
|
|
||||||
style={styles.lengthInput}
|
|
||||||
aria-label={label || "Enter measurement"}
|
|
||||||
/>
|
|
||||||
<Text style={styles.unitHints}>{mValue.u}</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
inputWrapper: {
|
|
||||||
alignItems: "flex-start",
|
|
||||||
flexDirection: "row"
|
|
||||||
},
|
|
||||||
unitHints: {
|
|
||||||
padding: 10,
|
|
||||||
},
|
|
||||||
lengthInput: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 4,
|
|
||||||
borderColor: "grey",
|
|
||||||
padding: 4,
|
|
||||||
margin: 4,
|
|
||||||
fontSize: 25,
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
})
|
|
@ -1,44 +0,0 @@
|
|||||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
|
||||||
import Slider from '@react-native-community/slider';
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
type PercentDamageProps = {
|
|
||||||
onSetPercentage: (percent: number) => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PercentDamage ({onSetPercentage} : PercentDamageProps) {
|
|
||||||
const [damage, setDamage] = useState(0);
|
|
||||||
function doOnChangeText (val : number) {
|
|
||||||
setDamage(val);
|
|
||||||
onSetPercentage(val / 100);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View style={styles.wrapper}>
|
|
||||||
<Slider
|
|
||||||
value={damage}
|
|
||||||
minimumValue={0}
|
|
||||||
maximumValue={100}
|
|
||||||
step={5}
|
|
||||||
onValueChange={doOnChangeText}
|
|
||||||
/>
|
|
||||||
<Text style={styles.label}> {damage}% Damage</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
wrapper: {
|
|
||||||
padding: 5,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
flex: 1,
|
|
||||||
margin: 5,
|
|
||||||
padding: 5,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: "lightgrey",
|
|
||||||
borderStyle: "solid",
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
margin: 5,
|
|
||||||
}
|
|
||||||
})
|
|
@ -1,38 +0,0 @@
|
|||||||
import { StyleSheet, Text, View } from "react-native";
|
|
||||||
|
|
||||||
export type PriceDisplayProps = {
|
|
||||||
price: number,
|
|
||||||
currency?: {
|
|
||||||
symbol: string,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PriceDisplay({ price }: PriceDisplayProps) {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.bigPriceWrapper} aria-label="calculated price">
|
|
||||||
<Text style={styles.bigPrice}>$ {price.toLocaleString(
|
|
||||||
undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}
|
|
||||||
)}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const styles = StyleSheet.create({
|
|
||||||
bigPriceWrapper: {
|
|
||||||
alignContent: "center",
|
|
||||||
},
|
|
||||||
bigPrice: {
|
|
||||||
alignSelf: "center",
|
|
||||||
fontSize: 40,
|
|
||||||
marginTop: 50,
|
|
||||||
marginBottom: 50,
|
|
||||||
}
|
|
||||||
});
|
|
@ -4,65 +4,46 @@ 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 = (key: string, newValue: string) => any;
|
export type ProductAttributeChangeFunc = (product_id: string, key: string, newValue: string) => any;
|
||||||
export type ProductAttributeDeleteFunc = (key: string) => any;
|
export type ProductAttributeDeleteFunc = (product_id: string, key: string) => any;
|
||||||
export type ChangeAttributeFunction = (oldKey : string, newKey : string) => any;
|
|
||||||
|
|
||||||
export type ProductAttributeProps = {
|
export type ProductAttributeProps = { product: Product, attributeKey: string, attributeValue: string, onChange?: ProductAttributeChangeFunc, onDelete?: ProductAttributeChangeFunc, };
|
||||||
attributeKey: string,
|
|
||||||
attributeValue: string,
|
|
||||||
onChangeAttributeKey?: ChangeAttributeFunction,
|
|
||||||
onChangeAttribute?: ProductAttributeChangeFunc,
|
|
||||||
onDelete?: ProductAttributeChangeFunc,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProductAttributeEditor = ({ attributeKey, attributeValue, onDelete, onChangeAttributeKey, onChangeAttribute }: ProductAttributeProps) => {
|
export const ProductAttributeEditor = ({ product, attributeKey: key, attributeValue: value, onDelete, onChange } : ProductAttributeProps) => {
|
||||||
|
const [doEdit, setDoEdit] = useState(true);
|
||||||
|
const [newValue, setNewValue] = useState(value);
|
||||||
|
|
||||||
const doChangeKey = (e: any) => {
|
const doChange = (e: any) => {
|
||||||
onChangeAttributeKey && onChangeAttributeKey(attributeKey, e);
|
setNewValue(e);
|
||||||
}
|
onChange && onChange(product.id, key, e);
|
||||||
|
|
||||||
const doChangeValue = (e: any) => {
|
|
||||||
onChangeAttribute && onChangeAttribute(attributeKey, e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View style={styles.productAttributeRow}>
|
<Text>{key}</Text>
|
||||||
<TextInput
|
<View>
|
||||||
defaultValue={attributeKey}
|
|
||||||
onChangeText={doChangeKey}
|
|
||||||
style={styles.value}
|
|
||||||
aria-label="Edit Key"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
defaultValue={attributeValue}
|
|
||||||
onChangeText={doChangeValue}
|
|
||||||
style={styles.value}
|
|
||||||
aria-label="Edit Value" />
|
|
||||||
<TouchableHighlight
|
<TouchableHighlight
|
||||||
onPress={() => onDelete && onDelete(attributeKey, attributeValue)}
|
onPress={() => setDoEdit(!doEdit)}
|
||||||
aria-label="Delete Attribute"
|
aria-label="Property Value"
|
||||||
style={{ backgroundColor: "darkred", borderRadius: 5, margin: 5, padding: 5, }}>
|
>
|
||||||
<Ionicons name="trash-bin-outline" size={30} color={"white"} />
|
{doEdit ?
|
||||||
|
(<Text>{newValue}</Text>) :
|
||||||
|
(<TextInput
|
||||||
|
value={newValue}
|
||||||
|
onChangeText={doChange}
|
||||||
|
aria-label="Edit Value" />)
|
||||||
|
}
|
||||||
|
</TouchableHighlight>
|
||||||
|
<TouchableHighlight
|
||||||
|
onPress={() => onDelete && onDelete(product.id, key, value)}
|
||||||
|
aria-label="Delete Attribute">
|
||||||
|
<Ionicons name="trash-bin-outline" />
|
||||||
</TouchableHighlight>
|
</TouchableHighlight>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const style = StyleSheet.create({
|
||||||
productAttributeRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
},
|
|
||||||
key: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
flex: 1,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "grey",
|
|
||||||
borderStyle: "solid",
|
|
||||||
padding: 10
|
|
||||||
}
|
|
||||||
});
|
});
|
@ -1,198 +0,0 @@
|
|||||||
import { Product, dimensions_t } from '@/lib/product';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import PriceDisplay from './Price';
|
|
||||||
import { AreaInput } from './AreaInput';
|
|
||||||
import { MeasurementInput } from './MeasurementInput';
|
|
||||||
import ProductList from './ProductList';
|
|
||||||
import UnitChooser from './UnitChooser';
|
|
||||||
import convert, { Length } from 'convert';
|
|
||||||
import PercentDamage from './PercentDamange';
|
|
||||||
|
|
||||||
|
|
||||||
export default function ProductCalculatorSelector() {
|
|
||||||
|
|
||||||
const [activeProduct, setActiveProduct] = useState(null as Product | null);
|
|
||||||
const [price, setPrice] = useState(0);
|
|
||||||
const [measurement, setMeasurement] = useState({ l: 0, w: 0, u: "ft" } as dimensions_t);
|
|
||||||
const [percentDamage, setPercentDamange] = useState(0.0);
|
|
||||||
|
|
||||||
useEffect(function () {
|
|
||||||
const iv = setInterval(function () {
|
|
||||||
if (!(activeProduct && measurement)) return;
|
|
||||||
setPrice(
|
|
||||||
activeProduct.priceFor(measurement, percentDamage)
|
|
||||||
)
|
|
||||||
}, 50);
|
|
||||||
return function () {
|
|
||||||
clearInterval(iv);
|
|
||||||
};
|
|
||||||
}, [activeProduct, measurement, percentDamage]);
|
|
||||||
|
|
||||||
function onMeasurementSet(dimensions: dimensions_t) {
|
|
||||||
setMeasurement(dimensions);
|
|
||||||
activeProduct && setPrice(
|
|
||||||
activeProduct.priceFor(measurement, percentDamage)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUnitChosen(unit: Length) {
|
|
||||||
setMeasurement({
|
|
||||||
...measurement,
|
|
||||||
u: unit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSetPercentDamage(pct: number) {
|
|
||||||
setPercentDamange(pct);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onProductSelected(product: Product) {
|
|
||||||
setActiveProduct(product);
|
|
||||||
setMeasurement(
|
|
||||||
{
|
|
||||||
l: convert(
|
|
||||||
product.dimensions.l,
|
|
||||||
product.dimensions.u,
|
|
||||||
).to(measurement.u),
|
|
||||||
u: measurement.u,
|
|
||||||
...(
|
|
||||||
("w" in measurement && "w" in product.dimensions) ? {
|
|
||||||
w: convert(
|
|
||||||
product.dimensions.w,
|
|
||||||
product.dimensions.u,
|
|
||||||
).to(measurement.u),
|
|
||||||
u: measurement.u,
|
|
||||||
} : {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.wrapper}>
|
|
||||||
<PriceDisplay price={price} />
|
|
||||||
<View style={styles.inputAndUnitWrapper}>
|
|
||||||
<View style={styles.inputWrapper}>
|
|
||||||
{
|
|
||||||
activeProduct ? (
|
|
||||||
"w" in activeProduct.dimensions ?
|
|
||||||
<AreaInput
|
|
||||||
defaultValue={activeProduct.dimensions}
|
|
||||||
onMeasurementSet={onMeasurementSet}
|
|
||||||
widthLabel='enter width'
|
|
||||||
lengthLabel='enter length'
|
|
||||||
/>
|
|
||||||
:
|
|
||||||
<MeasurementInput
|
|
||||||
defaultValue={activeProduct.dimensions}
|
|
||||||
onValueSet={onMeasurementSet}
|
|
||||||
label="enter length"
|
|
||||||
/>
|
|
||||||
|
|
||||||
) : (
|
|
||||||
<Text>Please select a product</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
activeProduct && <UnitChooser choices={["in", "ft"]} onChoicePressed={onUnitChosen} />
|
|
||||||
}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{activeProduct &&
|
|
||||||
(<View >
|
|
||||||
<PercentDamage
|
|
||||||
onSetPercentage={onSetPercentDamage}
|
|
||||||
/>
|
|
||||||
</View>)
|
|
||||||
}
|
|
||||||
<ProductList onProductSelected={onProductSelected} />
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const styles = StyleSheet.create({
|
|
||||||
wrapper: {
|
|
||||||
overflow: "scroll"
|
|
||||||
},
|
|
||||||
bigPriceWrapper: {
|
|
||||||
alignContent: "center",
|
|
||||||
},
|
|
||||||
bigPrice: {
|
|
||||||
alignSelf: "center",
|
|
||||||
fontSize: 40,
|
|
||||||
marginTop: 100,
|
|
||||||
marginBottom: 100,
|
|
||||||
},
|
|
||||||
inputWrapper: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
},
|
|
||||||
unitSelector: {
|
|
||||||
},
|
|
||||||
inputAndUnitWrapper: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignSelf: "center",
|
|
||||||
},
|
|
||||||
widthInput: {
|
|
||||||
width: 200,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 4,
|
|
||||||
borderColor: "grey",
|
|
||||||
padding: 4,
|
|
||||||
margin: 4,
|
|
||||||
fontSize: 30,
|
|
||||||
},
|
|
||||||
activeProduct: {
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: "black",
|
|
||||||
borderStyle: "solid",
|
|
||||||
},
|
|
||||||
inactiveProduct: {
|
|
||||||
|
|
||||||
},
|
|
||||||
titleContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
stepContainer: {
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
reactLogo: {
|
|
||||||
height: 178,
|
|
||||||
width: 290,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
},
|
|
||||||
productTileTouchable: {
|
|
||||||
margin: 10,
|
|
||||||
padding: 20,
|
|
||||||
backgroundColor: "grey",
|
|
||||||
},
|
|
||||||
|
|
||||||
productTileTouchableActive: {
|
|
||||||
borderWidth: 2,
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderColor: "black",
|
|
||||||
margin: 10,
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
|
|
||||||
productTileText: {
|
|
||||||
textAlign: "center",
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
|
|
||||||
productTileTextActive: {
|
|
||||||
textAlign: "center",
|
|
||||||
color: "black",
|
|
||||||
},
|
|
||||||
|
|
||||||
productTileCover: {
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
@ -1,6 +1,6 @@
|
|||||||
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 { deleteProduct, selectProducts, updateProduct } from "@/features/product/productSlice"
|
||||||
import { Id, Product, dimensions_t } from "@/lib/product";
|
import { Product } 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,50 +13,23 @@ export const ProductEditor = ({}) => {
|
|||||||
dispatch(deleteProduct(product_id));
|
dispatch(deleteProduct(product_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAttributeDelete(product_id: string, attribute: string) {
|
function onProductUpdated(product_id: string, product: Product) {
|
||||||
dispatch(deleteAttribute({product_id: product_id, attribute}));
|
dispatch(updateProduct(product));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 style={{overflow: "scroll"}}>
|
<SafeAreaView>
|
||||||
<Text>Edit Products</Text>
|
<Text>Hello</Text>
|
||||||
<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}
|
||||||
onAttributeDeleted={onAttributeDelete}
|
onProductUpdated={onProductUpdated}
|
||||||
onAttributeKeyChanged={onAttributeKeyChanged}
|
|
||||||
onAttributeUpdated={onAttributeUpdated}
|
|
||||||
onAttributeAdded={onAttributeAdded}
|
|
||||||
onPriceUpdated={onPriceUpdated}
|
|
||||||
onDimensionsUpdated={onDimensionUpdated}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -67,10 +40,6 @@ export const ProductEditor = ({}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
h1: {
|
|
||||||
textAlign: "center",
|
|
||||||
fontFamily: "sans-serif"
|
|
||||||
},
|
|
||||||
product: {
|
product: {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,161 +1,56 @@
|
|||||||
import { Id, Product, dimensions_t } from "@/lib/product"
|
import { Product } from "@/lib/product"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Button, FlatList, StyleSheet, Text, TextInput, Touchable, TouchableHighlight, View } from "react-native"
|
import { FlatList, StyleSheet, Text, TouchableHighlight, View } from "react-native"
|
||||||
import { ProductAttributeEditor } from "./ProductAttributeEditor";
|
import {ProductAttributeEditor} from "./ProductAttributeEditor";
|
||||||
import { Dropdown } from 'react-native-element-dropdown';
|
import React from "react";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Length } from "convert";
|
|
||||||
|
|
||||||
export type ProductAddedFunc = () => any;
|
export type ProductUpdatedFunc = (product_id: string, product: Product) => any;
|
||||||
export type ProductDeletedFunc = (product_id: Id) => any;
|
export type ProductDeletedFunc = (product_id: string) => 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,
|
||||||
onProductAdded?: ProductAddedFunc,
|
onProductUpdated?: ProductUpdatedFunc,
|
||||||
onProductDeleted?: ProductDeletedFunc,
|
onProductDeleted?: ProductDeletedFunc,
|
||||||
onAttributeAdded?: AttributeAddedFunc,
|
|
||||||
onAttributeKeyChanged?: AttributeKeyUpdatedFunc,
|
|
||||||
onAttributeUpdated?: AttributeUpdatedFunc,
|
|
||||||
onAttributeDeleted?: AttributeDeletedFunc,
|
|
||||||
onPriceUpdated?: PriceUpdatedFunc,
|
|
||||||
onDimensionsUpdated?: DimensionUpdatedFunc,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProductEditorItem = (props: ProductEditorItemProps) => {
|
export const ProductEditorItem = ({ product, onProductUpdated, onProductDeleted }: ProductEditorItemProps) => {
|
||||||
|
|
||||||
const [showAttributes, setShowAttributes] = useState(false);
|
const [showAttributes, setShowAttributes] = useState(false);
|
||||||
const product = props.product;
|
|
||||||
|
|
||||||
function onAttributeChanged(key: string, newValue: string) {
|
function onAttributeChanged(product_id: string, key: string, newValue: string) {
|
||||||
props.onAttributeUpdated && props.onAttributeUpdated(product.id, key, newValue);
|
product.attributes[key] = newValue;
|
||||||
|
onProductUpdated && onProductUpdated(product_id, product);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAttributeKeyChanged(oldKey: string, newKey: string) {
|
function onAttributeDelete(product_id: string, key: string) {
|
||||||
props.onAttributeKeyChanged && props.onAttributeKeyChanged(product.id, oldKey, newKey);
|
product.removeAttribute(key);
|
||||||
|
onProductDeleted && onProductDeleted(product_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAttributeDelete(key: string) {
|
|
||||||
props.onAttributeDeleted && props.onAttributeDeleted(product.id, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPricePerUnitChange(pricePerUnit: string) {
|
|
||||||
props.onPriceUpdated && props.onPriceUpdated(product.id, parseFloat(pricePerUnit) || parseInt(pricePerUnit));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUnitsChanged(newUnits: Length) {
|
|
||||||
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
|
|
||||||
...(product.dimensions as dimensions_t),
|
|
||||||
u: newUnits,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChangeLength(len: string) {
|
|
||||||
const l = parseFloat(len) || parseInt(len);
|
|
||||||
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
|
|
||||||
...(product.dimensions as dimensions_t),
|
|
||||||
l,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChangeWidth(width: string) {
|
|
||||||
const w = width.length == 0 ? null : parseFloat(width) || parseInt(width);
|
|
||||||
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
|
|
||||||
...(product.dimensions as dimensions_t),
|
|
||||||
...(w ? {w} : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDeleteProduct() {
|
|
||||||
props.onProductDeleted && props.onProductDeleted(product.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const length = new String(product.dimensions.l || product.dimensions.l || "0") 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}>
|
<TouchableHighlight
|
||||||
<TouchableHighlight
|
onPress={() => setShowAttributes(!showAttributes)}
|
||||||
onPress={() => setShowAttributes(!showAttributes)}
|
aria-label="Product Item"
|
||||||
aria-label="Product Item"
|
>
|
||||||
style={styles.productItemName}
|
<Text style={styles.product}>{product.attributes.name || `Product ${product.id}`} </Text>
|
||||||
>
|
</TouchableHighlight>
|
||||||
<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>
|
|
||||||
</View>
|
|
||||||
{showAttributes &&
|
{showAttributes &&
|
||||||
(
|
(
|
||||||
<View style={styles.detailsWrapper}>
|
<FlatList
|
||||||
<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>
|
)}
|
||||||
<Dropdown
|
keyExtractor={(item) => `${product.id}-${item.key}`}
|
||||||
data={[
|
/>
|
||||||
{label: "feet", value: "ft"},
|
|
||||||
{label: "inches", value: "in"},
|
|
||||||
]}
|
|
||||||
style={styles.unitsSelect}
|
|
||||||
mode="modal"
|
|
||||||
labelField="label"
|
|
||||||
valueField="value"
|
|
||||||
value={product.dimensions.u || "ft"}
|
|
||||||
onChange={(item) => onUnitsChanged(item.value as Length)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
inputMode="decimal"
|
|
||||||
defaultValue={length}
|
|
||||||
onChangeText={onChangeLength}
|
|
||||||
style={styles.lengthInput}
|
|
||||||
aria-label="length"
|
|
||||||
/>
|
|
||||||
<Text style={{flex: 1,}}>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>
|
||||||
@ -163,68 +58,5 @@ export const ProductEditorItem = (props: ProductEditorItemProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
deleteProductHighlight: {
|
product: {},
|
||||||
|
|
||||||
padding: 5,
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
deleteProductButton: {
|
|
||||||
fontSize: 20,
|
|
||||||
},
|
|
||||||
detailsWrapper: {
|
|
||||||
|
|
||||||
},
|
|
||||||
priceSpecWrapper: {
|
|
||||||
flexDirection: "row",
|
|
||||||
},
|
|
||||||
priceLabel: {
|
|
||||||
},
|
|
||||||
priceInput: {
|
|
||||||
flex: 1,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: "lightgrey",
|
|
||||||
borderStyle: "solid",
|
|
||||||
},
|
|
||||||
per: {
|
|
||||||
padding: 5,
|
|
||||||
},
|
|
||||||
unitsLabel: {
|
|
||||||
},
|
|
||||||
unitsSelect: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 5,
|
|
||||||
},
|
|
||||||
lengthInput: {
|
|
||||||
flex: 1,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: "lightgrey",
|
|
||||||
borderStyle: "solid",
|
|
||||||
},
|
|
||||||
widthInput: {
|
|
||||||
flex: 1,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: "lightgrey",
|
|
||||||
borderStyle: "solid",
|
|
||||||
},
|
|
||||||
productListHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
padding: 5,
|
|
||||||
},
|
|
||||||
productNameText: {
|
|
||||||
paddingLeft: 10,
|
|
||||||
paddingRight: 10,
|
|
||||||
},
|
|
||||||
productItemName: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "lightgrey",
|
|
||||||
padding: 4,
|
|
||||||
margin: 4,
|
|
||||||
},
|
|
||||||
productAttributesList: {
|
|
||||||
margin: 10,
|
|
||||||
padding: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderColor: "black",
|
|
||||||
},
|
|
||||||
})
|
})
|
@ -1,46 +0,0 @@
|
|||||||
import { FlatList, ScrollView, StyleSheet, Text, TouchableHighlight } from "react-native";
|
|
||||||
import { ProductTile } from "./ProductTile";
|
|
||||||
import { Id, Product } from "@/lib/product";
|
|
||||||
import { Key, useEffect, useState } from "react";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { selectProducts } from "@/features/product/productSlice";
|
|
||||||
import { useAppSelector } from "@/app/store";
|
|
||||||
|
|
||||||
export type ProductSelectionProps = {
|
|
||||||
onProductSelected?: (product: Product) => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProductList({ onProductSelected }: ProductSelectionProps) {
|
|
||||||
|
|
||||||
const [activeProduct, setActiveProduct] = useState(null as null | Product);
|
|
||||||
const products = useAppSelector(selectProducts).filter(p => (!!p.dimensions));
|
|
||||||
|
|
||||||
function doOnProductSelected(product: Product) {
|
|
||||||
setActiveProduct(product);
|
|
||||||
onProductSelected && onProductSelected(product);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView scrollToOverflowEnabled={true}>
|
|
||||||
{products.map(product => {
|
|
||||||
return (
|
|
||||||
<ProductTile
|
|
||||||
product={product}
|
|
||||||
onProductSelected={doOnProductSelected}
|
|
||||||
isActive={activeProduct === product}
|
|
||||||
key={product.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
|
|
||||||
productSelectorFlatList: {
|
|
||||||
padding: 10,
|
|
||||||
margin: 10,
|
|
||||||
},
|
|
||||||
|
|
||||||
})
|
|
@ -1,55 +1,44 @@
|
|||||||
import { Product } from "@/lib/product"
|
import { Product } from "@/lib/product"
|
||||||
import { ImageBackground, StyleProp, StyleSheet, Text, TouchableHighlight, View, ViewStyle } from "react-native";
|
import { ImageBackground, StyleProp, StyleSheet, Text, ViewStyle } from "react-native";
|
||||||
import { AnimatedStyle } from "react-native-reanimated";
|
import { AnimatedStyle } from "react-native-reanimated";
|
||||||
|
import { View } from "react-native-reanimated/lib/typescript/Animated";
|
||||||
|
|
||||||
export type OnProductSelectedFunc = (product : Product) => any;
|
export type OnProductSelectedFunc = (product : Product) => any;
|
||||||
|
|
||||||
type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
|
type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
|
||||||
|
|
||||||
type StyleSpec = {
|
|
||||||
highlight?: MyStyle,
|
|
||||||
text?: MyStyle,
|
|
||||||
image?: MyStyle,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export type ProductTileProps = {
|
export type ProductTileProps = {
|
||||||
product: (Product),
|
product: (Product),
|
||||||
onProductSelected?: OnProductSelectedFunc,
|
onProductSelected?: OnProductSelectedFunc,
|
||||||
isActive: boolean,
|
style?: {
|
||||||
|
tile?: MyStyle,
|
||||||
|
image?: MyStyle,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_IMAGE = "";
|
const FALLBACK_IMAGE = "";
|
||||||
|
|
||||||
export function ProductTile ({product, onProductSelected, isActive} : ProductTileProps) {
|
export function ProductTile ({product, onProductSelected, style} : ProductTileProps) {
|
||||||
const k = isActive ? "active" : "default";
|
const src = product.attributes.image || FALLBACK_IMAGE;
|
||||||
return (
|
return (
|
||||||
|
<View style={style?.tile}>
|
||||||
<TouchableHighlight
|
<ImageBackground
|
||||||
style={styles[k].highlight}
|
src={src}
|
||||||
onPress={() => onProductSelected && onProductSelected(product)}>
|
resizeMode="cover"
|
||||||
<Text style={styles[k].text}>{product.attributes.name || `Product ${product.id}`} ({product.pricePerUnitDisplay})</Text>
|
style={styles.image}
|
||||||
</TouchableHighlight>
|
>
|
||||||
|
<Text style={styles.text}>{product.attributes.name || `Product ${product.id}`}</Text>
|
||||||
|
<Text style={styles.text}>{ product.pricePerUnit.toString() } / {product.measure.value} {product.measure.unit.symbol} </Text>
|
||||||
|
</ImageBackground>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = {
|
const styles = StyleSheet.create({
|
||||||
active: StyleSheet.create({
|
image: {
|
||||||
highlight: {
|
|
||||||
padding: 10,
|
},
|
||||||
margin: 2,
|
text: {
|
||||||
color: "lightblue",
|
|
||||||
},
|
},
|
||||||
text: {
|
})
|
||||||
}
|
|
||||||
}),
|
|
||||||
default: StyleSheet.create({
|
|
||||||
highlight: {
|
|
||||||
padding: 10,
|
|
||||||
margin: 2,
|
|
||||||
backgroundColor: "lightgrey",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
import { Length } from "convert";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, StyleSheet, View } from "react-native";
|
|
||||||
|
|
||||||
export type UnitChooserProps = {
|
|
||||||
choices: Length[],
|
|
||||||
onChoicePressed: (l: Length) => any,
|
|
||||||
activeColor?: string,
|
|
||||||
defaultColor?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UnitChooser({ choices, onChoicePressed, activeColor, defaultColor }: UnitChooserProps) {
|
|
||||||
const [value, setValue] = useState(choices[0] as Length);
|
|
||||||
|
|
||||||
activeColor = activeColor || "lightblue";
|
|
||||||
defaultColor = defaultColor || "lightgrey";
|
|
||||||
|
|
||||||
function doChoiceClicked(choice: Length) {
|
|
||||||
setValue(choice);
|
|
||||||
onChoicePressed(choice);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.unitChooser}>
|
|
||||||
{choices.map((ci) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
title={ci}
|
|
||||||
onPress={() => doChoiceClicked(ci)}
|
|
||||||
color={value === ci ? activeColor : defaultColor}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
unitChooser: {
|
|
||||||
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
|
|
||||||
}
|
|
||||||
})
|
|
@ -1,31 +0,0 @@
|
|||||||
import { render, fireEvent, screen } from '@testing-library/react-native';
|
|
||||||
import { AreaInput } from '../AreaInput';
|
|
||||||
|
|
||||||
describe('AreaInput', () => {
|
|
||||||
it('renders correctly', () => {
|
|
||||||
render(<AreaInput lengthLabel='length' widthLabel='width' />);
|
|
||||||
const lengthInput = screen.getByLabelText('length');
|
|
||||||
const widthInput = screen.getByLabelText('width');
|
|
||||||
expect(lengthInput).toBeTruthy();
|
|
||||||
expect(widthInput).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onValueSet when a value is entered', () => {
|
|
||||||
const onMeasurementSetMock = jest.fn();
|
|
||||||
render(<AreaInput onMeasurementSet={onMeasurementSetMock} lengthLabel='length' widthLabel='width' defaultValue={{l: 4, w:4, u: "inch"}}/>);
|
|
||||||
const lengthInput = screen.getByLabelText('length');
|
|
||||||
const widthInput = screen.getByLabelText('width');
|
|
||||||
fireEvent.changeText(lengthInput, '10');
|
|
||||||
expect(onMeasurementSetMock).toHaveBeenCalledWith({
|
|
||||||
l: 10,
|
|
||||||
w: 4,
|
|
||||||
u: "inch"
|
|
||||||
});
|
|
||||||
fireEvent.changeText(widthInput, '10');
|
|
||||||
expect(onMeasurementSetMock).toHaveBeenCalledWith({
|
|
||||||
l: 10,
|
|
||||||
w: 10,
|
|
||||||
u: "inch"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,18 +0,0 @@
|
|||||||
import { render, fireEvent, screen } from '@testing-library/react-native';
|
|
||||||
import { MeasurementInput } from '../MeasurementInput';
|
|
||||||
|
|
||||||
describe('MeasurementInput', () => {
|
|
||||||
it('renders correctly', () => {
|
|
||||||
render(<MeasurementInput units="foot" defaultValue={10} />);
|
|
||||||
const input = screen.getByLabelText('Enter measurement');
|
|
||||||
expect(input).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onValueSet when value is changed', () => {
|
|
||||||
const mockOnValueSet = jest.fn();
|
|
||||||
render(<MeasurementInput units="foot" defaultValue={10} onValueSet={mockOnValueSet} />);
|
|
||||||
const input = screen.getByLabelText('Enter measurement');
|
|
||||||
fireEvent.changeText(input, '20');
|
|
||||||
expect(mockOnValueSet).toHaveBeenCalledWith({ l: 20, u: 'foot' });
|
|
||||||
});
|
|
||||||
});
|
|
@ -6,12 +6,10 @@ 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,
|
||||||
{l: 100, u: "foot"},
|
area("squareFoot", 4 * 7)
|
||||||
{"name" : productName}
|
|
||||||
);
|
);
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
const onDelete = jest.fn();
|
const onDelete = jest.fn();
|
||||||
@ -20,7 +18,7 @@ describe("Product editor tests", () => {
|
|||||||
attributeKey="name"
|
attributeKey="name"
|
||||||
attributeValue="product"
|
attributeValue="product"
|
||||||
product={product}
|
product={product}
|
||||||
onChangeAttribute={onChange}
|
onChange={onChange}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>);
|
/>);
|
||||||
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
|
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
|
||||||
@ -28,28 +26,25 @@ 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,
|
||||||
{l: 100, u: "foot"},
|
area("squareFoot", 4 * 7),
|
||||||
{"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="old test key"
|
attributeKey="Name"
|
||||||
attributeValue="old test value"
|
attributeValue="product"
|
||||||
onChangeAttribute={onChange}
|
product={product}
|
||||||
|
onChange={onChange}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onChangeAttributeKey={onKeyChange}
|
|
||||||
/>);
|
/>);
|
||||||
fireEvent.changeText(screen.getByLabelText("Edit Key"), "new test key");
|
fireEvent.press(screen.getByText("product")); // Use getByText instead of findByText
|
||||||
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,72 +0,0 @@
|
|||||||
import { render, fireEvent, screen, act, within } from '@testing-library/react-native';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
|
|
||||||
import { renderWithProviders } from '@/lib/rendering';
|
|
||||||
import { Product } from '@/lib/product';
|
|
||||||
|
|
||||||
describe('ProductCalculatorSelector', () => {
|
|
||||||
|
|
||||||
const mockAreaProduct = new Product(
|
|
||||||
100,
|
|
||||||
{ l: 4, w: 8, u: "ft" },
|
|
||||||
{"name": "area product"},
|
|
||||||
);
|
|
||||||
const mockLengthProduct = new Product(
|
|
||||||
100,
|
|
||||||
{ l: 4, u: "ft" },
|
|
||||||
{"name": "length product"},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('renders correctly', () => {
|
|
||||||
renderWithProviders(
|
|
||||||
(<ProductCalculatorSelector />),
|
|
||||||
{
|
|
||||||
products: [
|
|
||||||
mockAreaProduct.asObject,
|
|
||||||
mockLengthProduct.asObject,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText('Please select a product')).toBeTruthy();
|
|
||||||
const label = `${mockAreaProduct.attributes.name} (${mockAreaProduct.pricePerUnitDisplay})`;
|
|
||||||
expect(screen.getByText(label)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a product can be selected', () => {
|
|
||||||
renderWithProviders(
|
|
||||||
(<ProductCalculatorSelector />),
|
|
||||||
{
|
|
||||||
products: [
|
|
||||||
mockLengthProduct.asObject,
|
|
||||||
mockAreaProduct.asObject,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Please select a product')).toBeTruthy();
|
|
||||||
const areaLabel = `${mockAreaProduct.attributes.name} (${mockAreaProduct.pricePerUnitDisplay})`;
|
|
||||||
const lengthLabel = `${mockLengthProduct.attributes.name} (${mockLengthProduct.pricePerUnitDisplay})`;
|
|
||||||
|
|
||||||
fireEvent.press(screen.getByText(areaLabel));
|
|
||||||
const lengthInput = screen.getByLabelText("enter length");
|
|
||||||
const widthInput = screen.getByLabelText("enter length");
|
|
||||||
expect(lengthInput).toBeTruthy();
|
|
||||||
expect(widthInput).toBeTruthy();
|
|
||||||
|
|
||||||
fireEvent.press(screen.getByText("in"));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
fireEvent.changeText(lengthInput, "2");
|
|
||||||
fireEvent.changeText(widthInput, "4");
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(3000);
|
|
||||||
|
|
||||||
const price = mockAreaProduct.priceFor({l: 2, w: 4, u: "ft"});
|
|
||||||
const sPrice = price.toLocaleString(undefined, {maximumFractionDigits: 2, minimumFractionDigits: 2,});
|
|
||||||
const element = screen.getByLabelText("calculated price");
|
|
||||||
const {getByText} = within(element);
|
|
||||||
expect(getByText(sPrice)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,51 +1,25 @@
|
|||||||
import { renderWithProviders } from "@/lib/rendering";
|
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 {products as fixtures} from "@/__fixtures__/initialProducts";
|
||||||
|
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: [
|
products: fixtures,
|
||||||
mockProduct.asObject,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const state1 = store.getState();
|
const state1 = store.getState();
|
||||||
|
|
||||||
let products = selectProducts(state1);
|
const products = selectProducts(state1);
|
||||||
|
|
||||||
expect(products).toHaveLength(1);
|
expect(products).toHaveLength(6);
|
||||||
|
|
||||||
// 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();
|
||||||
// Start to edit a product
|
expect(screen.getByText(products[2].attributes.name as string)).toBeTruthy();
|
||||||
fireEvent.press(screen.getByText(productName));
|
expect(screen.getByText(products[3].attributes.name as string)).toBeTruthy();
|
||||||
|
|
||||||
// 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,36 +3,22 @@ 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,
|
||||||
{l: 4, u: 'feet'},
|
area("squareFoot", 4 * 8),
|
||||||
{"name": productName},
|
{"name": "Product 1"},
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAttributeAdded = jest.fn();
|
const mockOnProductUpdated = 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}
|
||||||
onAttributeAdded={onAttributeAdded}
|
onProductUpdated={mockOnProductUpdated}
|
||||||
onAttributeDeleted={onAttributeDeleted}
|
|
||||||
onAttributeKeyChanged={onAttributeKeyChanged}
|
|
||||||
onAttributeUpdated={onAttributeUpdated}
|
|
||||||
onProductAdded={onProductAdded}
|
|
||||||
onPriceUpdated={onPriceUpdated}
|
|
||||||
onDimensionsUpdated={onDimensionUpdated}
|
|
||||||
onProductDeleted={mockOnProductDeleted}
|
onProductDeleted={mockOnProductDeleted}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -40,37 +26,15 @@ describe('ProductEditorItem', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls onProductUpdated when TouchableHighlight is pressed', () => {
|
it('calls onProductUpdated when TouchableHighlight is pressed', () => {
|
||||||
const {store} = renderWithProviders(
|
render(
|
||||||
<ProductEditorItem
|
<ProductEditorItem
|
||||||
product={mockProduct}
|
product={mockProduct}
|
||||||
onAttributeAdded={onAttributeAdded}
|
onProductUpdated={mockOnProductUpdated}
|
||||||
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.getByLabelText("units")).toBeTruthy();
|
expect(screen.getByText('name')).toBeTruthy();
|
||||||
expect(screen.getByLabelText("Edit Key")).toBeTruthy();
|
expect(screen.getAllByText('Product 1').length).toEqual(2);
|
||||||
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,29 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, fireEvent } from '@testing-library/react-native';
|
|
||||||
import UnitChooser from "../UnitChooser";
|
|
||||||
import { Length } from 'safe-units';
|
|
||||||
|
|
||||||
describe('UnitChooser', () => {
|
|
||||||
const mockOnChoicePressed = jest.fn();
|
|
||||||
const choices = ['foot', 'inch'] as Length [];
|
|
||||||
|
|
||||||
it('renders correctly', () => {
|
|
||||||
const { getByText } = render(
|
|
||||||
<UnitChooser choices={choices} onChoicePressed={mockOnChoicePressed} />
|
|
||||||
);
|
|
||||||
|
|
||||||
choices.forEach(choice => {
|
|
||||||
expect(getByText(choice)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onChoicePressed when a button is pressed', () => {
|
|
||||||
const { getByText } = render(
|
|
||||||
<UnitChooser choices={choices} onChoicePressed={mockOnChoicePressed} />
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.press(getByText(choices[0]));
|
|
||||||
|
|
||||||
expect(mockOnChoicePressed).toHaveBeenCalledWith(choices[0]);
|
|
||||||
});
|
|
||||||
});
|
|
18
eas.json
18
eas.json
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"cli": {
|
|
||||||
"version": ">= 10.0.3"
|
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"development": {
|
|
||||||
"developmentClient": true,
|
|
||||||
"distribution": "internal"
|
|
||||||
},
|
|
||||||
"preview": {
|
|
||||||
"distribution": "internal"
|
|
||||||
},
|
|
||||||
"production": {}
|
|
||||||
},
|
|
||||||
"submit": {
|
|
||||||
"production": {}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,36 +1,17 @@
|
|||||||
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { area_t, dimensions_t, Id, length_t, Product, ProductData } from '@/lib/product';
|
import { Id, Product } 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 ProductData[],
|
products: [] as Product [],
|
||||||
}
|
}
|
||||||
|
|
||||||
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<ProductData>) {
|
createProduct(state, action: PayloadAction<Product>) {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return initialState
|
return initialState
|
||||||
}
|
}
|
||||||
@ -39,133 +20,31 @@ 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;
|
||||||
return {
|
state.products = state.products.filter((prod) => {
|
||||||
...state,
|
prod.id !== action.payload;
|
||||||
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 selectProductsDatas = (state: RootState) => {
|
export const selectProducts = (state : RootState) => {
|
||||||
return state.products;
|
return state.products;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectProducts = createSelector([selectProductsDatas], productsData => {
|
|
||||||
return productsData.map(d => Product.fromObject(d));
|
|
||||||
})
|
|
||||||
|
|
||||||
export const selectProductIds = createSelector([selectProducts], products => {
|
|
||||||
return products.map(p => p.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
|
||||||
@ -173,13 +52,8 @@ 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;
|
||||||
|
@ -20,11 +20,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);
|
|
||||||
})
|
|
||||||
});
|
});
|
139
lib/product.ts
139
lib/product.ts
@ -1,6 +1,5 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -15,6 +14,18 @@ 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
|
||||||
}
|
}
|
||||||
@ -25,77 +36,55 @@ 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 function matchDimensions(d1: dimensions_t, d2: dimensions_t) {
|
|
||||||
if (!
|
|
||||||
(
|
|
||||||
(isArea(d1) && isArea(d2)) ||
|
|
||||||
(isLength(d1) && isLength(d2))
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(`Dimension mismatch: ${JSON.stringify(d1)} / ${JSON.stringify(d1)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
l: convert(d1.l, d1.u).to(d2.u),
|
|
||||||
u: d2.u,
|
|
||||||
...(
|
|
||||||
"w" in d1 ?
|
|
||||||
{ w: convert(d1.w, d1.u).to(d2.u), }
|
|
||||||
: {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dimensionArea(d: dimensions_t) {
|
|
||||||
return "w" in d ? d.w * d.l : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Product {
|
export class Product {
|
||||||
|
public id: string;
|
||||||
|
public area?: area_t;
|
||||||
|
public length?: length_t;
|
||||||
|
public presentUnits: Length;
|
||||||
|
|
||||||
public id?: Id;
|
constructor(public pricePerUnit: number, dimensions: dimensions_t, public attributes: ProductAttributes = {},) {
|
||||||
|
this.id = attributes.id || uuid.v4().toString();
|
||||||
constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {},
|
this.presentUnits = dimensions.u;
|
||||||
id?: Id,
|
if ("w" in dimensions) {
|
||||||
) {
|
this.area = {
|
||||||
this.id = id || uuid.v4().toString();
|
l: convert(dimensions.l, dimensions.u).to("meter"),
|
||||||
|
w: convert(dimensions.w, dimensions.u).to("meter"),
|
||||||
|
u: "meter"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.length = {
|
||||||
|
l: convert(dimensions.l, dimensions.u).to("meter"),
|
||||||
|
u: "meter"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public priceFor(dimensions: dimensions_t, damage : number): number {
|
public priceFor(dimensions: dimensions_t): number {
|
||||||
if (Number.isNaN(damage)) damage = 0;
|
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
|
||||||
) * (1.0 - damage);
|
).to("meter") * convert(
|
||||||
}
|
dimensions.l,
|
||||||
|
dimensions.u
|
||||||
get priceDisplay() {
|
).to("meter");
|
||||||
return this.pricePerUnit.toLocaleString(undefined, {
|
return (otherA / thisA) * this.pricePerUnit;
|
||||||
minimumFractionDigits: 2,
|
} if (this.length) {
|
||||||
maximumFractionDigits: 2,
|
const thisL = this.length.l;
|
||||||
})
|
const otherL = convert(
|
||||||
}
|
dimensions.l,
|
||||||
|
dimensions.u
|
||||||
get pricePerUnitDisplay() {
|
).to("meter");
|
||||||
const p = this.priceDisplay;
|
return (otherL / thisL) * this.pricePerUnit;
|
||||||
const { l, u } = this.dimensions;
|
}
|
||||||
const w = (this.dimensions as area_t).w || null;
|
throw new Error(`Invalid dimensions: ${dimensions}`);
|
||||||
const d = w ? `${l}${u} x ${w}${u}` : `${l}${u}`;
|
|
||||||
return `$${p} per ${d}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get attributesAsList() {
|
get attributesAsList() {
|
||||||
@ -105,30 +94,6 @@ export class Product {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public removeAttribute(key: string) {
|
public removeAttribute(key: string) {
|
||||||
this.attributes = Object.fromEntries(
|
delete this.attributes[key];
|
||||||
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, ProductData } from "@/lib/product";
|
import { Product } 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 ProductData []
|
products: [] as Product []
|
||||||
},
|
},
|
||||||
extendedRenderOptions: ExtendedRenderOptions = {},
|
extendedRenderOptions: ExtendedRenderOptions = {},
|
||||||
) {
|
) {
|
||||||
|
18022
package-lock.json
generated
Normal file
18022
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -5,9 +5,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo run:android",
|
"android": "expo start --android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web --offline",
|
"web": "expo start --web",
|
||||||
"test": "jest --watchAll",
|
"test": "jest --watchAll",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
@ -15,16 +15,15 @@
|
|||||||
"@babel/runtime": "^7.24.7",
|
"@babel/runtime": "^7.24.7",
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
"@react-native-async-storage/async-storage": "^1.23.1",
|
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||||
"@react-native-community/slider": "^4.5.2",
|
"@react-native/assets-registry": "^0.74.84",
|
||||||
"@react-native/assets-registry": "^0.74.85",
|
|
||||||
"@react-navigation/native": "^6.1.17",
|
"@react-navigation/native": "^6.1.17",
|
||||||
"@reduxjs/toolkit": "^2.2.6",
|
"@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",
|
||||||
"expo": "~51.0.17",
|
"esm": "link:@types/js-quantities/esm",
|
||||||
|
"expo": "~51.0.16",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-font": "~12.0.7",
|
"expo-font": "~12.0.7",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
@ -33,22 +32,19 @@
|
|||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "~3.0.6",
|
"expo-system-ui": "~3.0.6",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
|
"interopRequireDefault": "link:@babel/runtime/helpers/interopRequireDefault",
|
||||||
"js-quantities": "^1.8.0",
|
"js-quantities": "^1.8.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.2",
|
"react-native": "0.74.2",
|
||||||
"react-native-element-dropdown": "^2.12.1",
|
|
||||||
"react-native-flex-grid": "^1.0.4",
|
|
||||||
"react-native-gesture-handler": "~2.16.2",
|
"react-native-gesture-handler": "~2.16.2",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@ -61,7 +57,6 @@
|
|||||||
"@types/react-test-renderer": "^18.3.0",
|
"@types/react-test-renderer": "^18.3.0",
|
||||||
"babel-plugin-transform-es2015-destructuring": "^6.23.0",
|
"babel-plugin-transform-es2015-destructuring": "^6.23.0",
|
||||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||||
"eas-cli": "^10.1.0",
|
|
||||||
"enzyme-adapter-react-15": "^1.4.4",
|
"enzyme-adapter-react-15": "^1.4.4",
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
4080
pnpm-lock.yaml
4080
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user