add product test
This commit is contained in:
parent
839c3f8380
commit
e560f51fe7
16 changed files with 768 additions and 187 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -135,8 +135,7 @@ dist
|
||||||
type-clusters/*
|
type-clusters/*
|
||||||
|
|
||||||
# ---> Maestro (root E2E suite)
|
# ---> Maestro (root E2E suite)
|
||||||
/e2e/.maestro-output/
|
tests/e2e/.env
|
||||||
/e2e/.env
|
|
||||||
|
|
||||||
apps/user-ui/android/
|
apps/user-ui/android/
|
||||||
apps/admin-ui/android/
|
apps/admin-ui/android/
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ const MenuItemComponent: React.FC<MenuItemComponentProps> = ({ item, router }) =
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
testID={item.testID}
|
||||||
|
accessibilityLabel={item.testID}
|
||||||
onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)}
|
onPress={() => item.onPress ? item.onPress() : router.push(item.route as any)}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`,
|
tw`flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm`,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Alert } from 'react-native'
|
import { Alert, View } from 'react-native'
|
||||||
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui'
|
import { ImageUploaderNeoPayload, tw } from 'common-ui'
|
||||||
import ProductForm from '@/src/components/ProductForm'
|
import ProductForm from '@/src/components/ProductForm'
|
||||||
import { trpc } from '@/src/trpc-client'
|
import { trpc } from '@/src/trpc-client'
|
||||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'
|
||||||
|
|
@ -139,13 +139,13 @@ export default function AddProduct() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<View style={tw`flex-1 bg-white`}>
|
||||||
<ProductForm
|
<ProductForm
|
||||||
mode="create"
|
mode="create"
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isLoading={createProduct.isPending || isUploading}
|
isLoading={createProduct.isPending || isUploading}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ export default function EditProduct() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<View style={tw`flex-1 bg-white`}>
|
||||||
<ProductForm
|
<ProductForm
|
||||||
ref={productFormRef}
|
ref={productFormRef}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
|
|
@ -229,6 +229,6 @@ export default function EditProduct() {
|
||||||
existingVariantImages={existingVariantImages}
|
existingVariantImages={existingVariantImages}
|
||||||
existingVariantImageKeys={existingVariantImageKeys}
|
existingVariantImageKeys={existingVariantImageKeys}
|
||||||
/>
|
/>
|
||||||
</AppContainer>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { View, TouchableOpacity, RefreshControl } from 'react-native';
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
import { MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||||
|
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { SuccessToast, ErrorToast } from '@/services/toaster';
|
import { SuccessToast, ErrorToast } from '@/services/toaster';
|
||||||
|
|
@ -13,15 +13,130 @@ type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
||||||
|
|
||||||
type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date }
|
type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date }
|
||||||
|
|
||||||
function getDefaultSku(product: { skus: SerializedAdminSku[] }): SerializedAdminSku | null {
|
function getDefaultSku(product: { skus?: SerializedAdminSku[] }): SerializedAdminSku | null {
|
||||||
return product.skus?.[0] ?? null
|
return product.skus?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FilterButton({
|
||||||
|
filter,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
activeFilter,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
filter: FilterType
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
activeFilter: FilterType
|
||||||
|
onSelect: (filter: FilterType) => void
|
||||||
|
}) {
|
||||||
|
const isActive = activeFilter === filter
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onSelect(filter)}
|
||||||
|
style={tw`px-4 py-2 rounded-lg ${isActive ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`${isActive ? 'text-white' : 'text-gray-700'} font-semibold`}>
|
||||||
|
{label} ({count})
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductRowProps = {
|
||||||
|
product: any
|
||||||
|
isToggling: boolean
|
||||||
|
onView: (productId: number) => void
|
||||||
|
onEdit: (productId: number) => void
|
||||||
|
onToggle: (product: { id: number }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductRow = React.memo(function ProductRow({ product, isToggling, onView, onEdit, onToggle }: ProductRowProps) {
|
||||||
|
const defaultSku = getDefaultSku(product)
|
||||||
|
const skuImages = defaultSku?.images ?? null
|
||||||
|
const isOut = defaultSku?.isOutOfStock ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}>
|
||||||
|
{/* Product Image */}
|
||||||
|
{skuImages && skuImages.length > 0 ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: skuImages[0] }}
|
||||||
|
style={tw`w-full h-48`}
|
||||||
|
contentFit="cover"
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
recyclingKey={String(product.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={tw`w-full h-48 bg-gray-200 justify-center items-center`}>
|
||||||
|
<MaterialIcons name="image" size={48} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<View style={tw`p-4`}>
|
||||||
|
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||||
|
<MyText style={tw`text-lg font-bold text-gray-800 flex-1 mr-2`} numberOfLines={2}>
|
||||||
|
{product.name}
|
||||||
|
</MyText>
|
||||||
|
<View style={tw`flex-row items-center`}>
|
||||||
|
<View style={tw`w-3 h-3 rounded-full mr-1 ${isOut ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||||
|
<MyText style={tw`text-sm ${isOut ? 'text-red-500' : 'text-green-500'} font-semibold`}>
|
||||||
|
{isOut ? 'Out' : 'In'}
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{product.shortDescription && (
|
||||||
|
<MyText style={tw`text-gray-600 mb-2`} numberOfLines={2}>
|
||||||
|
{product.shortDescription}
|
||||||
|
</MyText>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||||
|
<MyText style={tw`text-sm text-gray-500 font-medium`}>
|
||||||
|
{product.skus?.length ?? 0} Variants
|
||||||
|
</MyText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<View style={tw`flex-row gap-2`}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onView(product.id)}
|
||||||
|
style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="visibility" size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>View</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onEdit(product.id)}
|
||||||
|
style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="edit" size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onToggle(product)}
|
||||||
|
disabled={isToggling}
|
||||||
|
style={tw`flex-1 ${isOut ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center ${isToggling ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name={isOut ? 'check-circle' : 'block'} size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>
|
||||||
|
{isOut ? 'Stock' : 'Out'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export default function Products() {
|
export default function Products() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
|
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
|
||||||
|
|
||||||
|
|
@ -43,18 +158,17 @@ export default function Products() {
|
||||||
|
|
||||||
const products = productsData?.products || [];
|
const products = productsData?.products || [];
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
|
||||||
await refetch();
|
await refetch();
|
||||||
setRefreshing(false);
|
}, [refetch]);
|
||||||
};
|
|
||||||
|
|
||||||
const filteredProducts = useMemo(() => {
|
const filteredProducts = useMemo(() => {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
return products.filter(product => {
|
return products.filter(product => {
|
||||||
const defaultSku = getDefaultSku(product)
|
const defaultSku = getDefaultSku(product)
|
||||||
|
|
||||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = product.name.toLowerCase().includes(term) ||
|
||||||
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()));
|
(product.shortDescription?.toLowerCase().includes(term));
|
||||||
|
|
||||||
const matchesFilter = activeFilter === 'all' ||
|
const matchesFilter = activeFilter === 'all' ||
|
||||||
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
|
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
|
||||||
|
|
@ -64,64 +178,47 @@ export default function Products() {
|
||||||
});
|
});
|
||||||
}, [products, searchTerm, activeFilter]);
|
}, [products, searchTerm, activeFilter]);
|
||||||
|
|
||||||
const handleEdit = (productId: number) => {
|
const inStockCount = useMemo(
|
||||||
router.push(`/(drawer)/dashboard/products/edit?id=${productId}` as any);
|
() => products.filter(p => { const sku = getDefaultSku(p); return sku && !sku.isOutOfStock }).length,
|
||||||
};
|
[products]
|
||||||
|
);
|
||||||
const handleViewDetails = (productId: number) => {
|
const outOfStockCount = useMemo(
|
||||||
router.push(`/(drawer)/dashboard/products/detail/${productId}` as any);
|
() => products.filter(p => getDefaultSku(p)?.isOutOfStock).length,
|
||||||
};
|
[products]
|
||||||
|
|
||||||
const handleToggleStock = (product: { id: number }) => {
|
|
||||||
toggleOutOfStock.mutate({ id: product.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const FilterButton = ({ filter, label, count }: { filter: FilterType; label: string; count: number }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setActiveFilter(filter)}
|
|
||||||
style={tw`px-4 py-2 rounded-lg ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`}
|
|
||||||
>
|
|
||||||
<MyText style={tw`${activeFilter === filter ? 'text-white' : 'text-gray-700'} font-semibold`}>
|
|
||||||
{label} ({count})
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
const handleEdit = useCallback((productId: number) => {
|
||||||
return (
|
router.push(`/(drawer)/dashboard/products/edit?id=${productId}` as any);
|
||||||
<AppContainer>
|
}, [router]);
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
|
||||||
<MyText style={tw`text-gray-600`}>Loading products...</MyText>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
const handleViewDetails = useCallback((productId: number) => {
|
||||||
return (
|
router.push(`/(drawer)/dashboard/products/detail/${productId}` as any);
|
||||||
<AppContainer>
|
}, [router]);
|
||||||
<View style={tw`flex-1 justify-center items-center`}>
|
|
||||||
<MyText style={tw`text-red-600`}>Error loading products</MyText>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => refetch()}
|
|
||||||
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
|
||||||
>
|
|
||||||
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</AppContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inStockCount = products.filter(p => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length;
|
const handleToggleStock = useCallback((product: { id: number }) => {
|
||||||
const outOfStockCount = products.filter(p => getDefaultSku(p)?.isOutOfStock).length;
|
toggleOutOfStock.mutate({ id: product.id });
|
||||||
|
}, [toggleOutOfStock]);
|
||||||
|
|
||||||
const listHeader = (
|
const handleSelectFilter = useCallback((filter: FilterType) => setActiveFilter(filter), []);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: { id: number }) => item.id.toString(), []);
|
||||||
|
|
||||||
|
const renderItem = useCallback(({ item: product }: { item: any }) => (
|
||||||
|
<ProductRow
|
||||||
|
product={product}
|
||||||
|
isToggling={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id}
|
||||||
|
onView={handleViewDetails}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onToggle={handleToggleStock}
|
||||||
|
/>
|
||||||
|
), [toggleOutOfStock.isPending, toggleOutOfStock.variables?.id, handleViewDetails, handleEdit, handleToggleStock]);
|
||||||
|
|
||||||
|
const listHeader = useMemo(() => (
|
||||||
<>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={tw`flex-row justify-between items-center mb-6`}>
|
<View style={tw`flex-row justify-between items-center mb-6`}>
|
||||||
<MyText style={tw`text-2xl font-bold text-gray-800`}>Products</MyText>
|
<MyText style={tw`text-2xl font-bold text-gray-800`}>Products</MyText>
|
||||||
<MyButton onPress={() => router.push('/(drawer)/dashboard/products/add' as any)}>
|
<MyButton testID="add-product-button" onPress={() => router.push('/(drawer)/dashboard/products/add' as any)}>
|
||||||
Add Product
|
Add Product
|
||||||
</MyButton>
|
</MyButton>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -138,130 +235,72 @@ export default function Products() {
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs */}
|
||||||
<View style={tw`flex-row gap-2 mb-6`}>
|
<View style={tw`flex-row gap-2 mb-6`}>
|
||||||
<FilterButton filter="all" label="All" count={products.length} />
|
<FilterButton filter="all" label="All" count={products.length} activeFilter={activeFilter} onSelect={handleSelectFilter} />
|
||||||
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} />
|
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} activeFilter={activeFilter} onSelect={handleSelectFilter} />
|
||||||
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} />
|
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} activeFilter={activeFilter} onSelect={handleSelectFilter} />
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
), [products.length, inStockCount, outOfStockCount, searchTerm, activeFilter, handleSelectFilter, router]);
|
||||||
|
|
||||||
const renderItem = ({ item: product }: { item: any }) => {
|
|
||||||
const defaultSku = getDefaultSku(product)
|
|
||||||
const skuImages = defaultSku?.images ?? null
|
|
||||||
const isOut = defaultSku?.isOutOfStock ?? false
|
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View key={product.id} style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}>
|
<View style={tw`flex-1 bg-white justify-center items-center`}>
|
||||||
{/* Product Image */}
|
<MyText style={tw`text-gray-600`}>Loading products...</MyText>
|
||||||
{skuImages && skuImages.length > 0 ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: skuImages[0] }}
|
|
||||||
style={tw`w-full h-48`}
|
|
||||||
resizeMode="cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={tw`w-full h-48 bg-gray-200 justify-center items-center`}>
|
|
||||||
<MaterialIcons name="image" size={48} color="#9CA3AF" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Product Info */}
|
|
||||||
<View style={tw`p-4`}>
|
|
||||||
<View style={tw`flex-row justify-between items-start mb-2`}>
|
|
||||||
<MyText style={tw`text-lg font-bold text-gray-800 flex-1 mr-2`} numberOfLines={2}>
|
|
||||||
{product.name}
|
|
||||||
</MyText>
|
|
||||||
<View style={tw`flex-row items-center`}>
|
|
||||||
<View style={tw`w-3 h-3 rounded-full mr-1 ${isOut ? 'bg-red-500' : 'bg-green-500'}`} />
|
|
||||||
<MyText style={tw`text-sm ${isOut ? 'text-red-500' : 'text-green-500'} font-semibold`}>
|
|
||||||
{isOut ? 'Out' : 'In'}
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{product.shortDescription && (
|
|
||||||
<MyText style={tw`text-gray-600 mb-2`} numberOfLines={2}>
|
|
||||||
{product.shortDescription}
|
|
||||||
</MyText>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={tw`flex-row justify-between items-center mb-3`}>
|
|
||||||
<MyText style={tw`text-sm text-gray-500 font-medium`}>
|
|
||||||
{product.skus?.length ?? 0} Variants
|
|
||||||
</MyText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<View style={tw`flex-row gap-2`}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleViewDetails(product.id)}
|
|
||||||
style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="visibility" size={16} color="white" />
|
|
||||||
<MyText style={tw`text-white font-semibold ml-1`}>View</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleEdit(product.id)}
|
|
||||||
style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="edit" size={16} color="white" />
|
|
||||||
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleToggleStock(product)}
|
|
||||||
disabled={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id}
|
|
||||||
style={tw`flex-1 ${isOut ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center ${toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<MaterialIcons name={isOut ? 'check-circle' : 'block'} size={16} color="white" />
|
|
||||||
<MyText style={tw`text-white font-semibold ml-1`}>
|
|
||||||
{isOut ? 'Stock' : 'Out'}
|
|
||||||
</MyText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={tw`flex-1 bg-white justify-center items-center`}>
|
||||||
|
<MyText style={tw`text-red-600`}>Error loading products</MyText>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => refetch()}
|
||||||
|
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||||
|
>
|
||||||
|
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<View style={tw`flex-1 bg-white`}>
|
||||||
<View style={tw`flex-1`}>
|
<MyFlatList
|
||||||
<MyFlatList
|
data={filteredProducts}
|
||||||
data={filteredProducts}
|
renderItem={renderItem}
|
||||||
renderItem={renderItem}
|
keyExtractor={keyExtractor}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
ListHeaderComponent={listHeader}
|
||||||
ListHeaderComponent={listHeader}
|
ListEmptyComponent={
|
||||||
ListEmptyComponent={
|
<View style={tw`flex-1 justify-center items-center py-10`}>
|
||||||
<View style={tw`flex-1 justify-center items-center py-10`}>
|
<MaterialIcons name="inventory" size={64} color="#D1D5DB" />
|
||||||
<MaterialIcons name="inventory" size={64} color="#D1D5DB" />
|
<MyText style={tw`text-gray-500 text-center mt-4 text-lg`}>
|
||||||
<MyText style={tw`text-gray-500 text-center mt-4 text-lg`}>
|
{searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'}
|
||||||
{searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'}
|
</MyText>
|
||||||
</MyText>
|
<MyText style={tw`text-gray-400 text-center mt-2`}>
|
||||||
<MyText style={tw`text-gray-400 text-center mt-2`}>
|
{searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'}
|
||||||
{searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'}
|
</MyText>
|
||||||
</MyText>
|
{(searchTerm || activeFilter !== 'all') && (
|
||||||
{(searchTerm || activeFilter !== 'all') && (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
onPress={() => {
|
||||||
onPress={() => {
|
setSearchTerm('');
|
||||||
setSearchTerm('');
|
setActiveFilter('all');
|
||||||
setActiveFilter('all');
|
}}
|
||||||
}}
|
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||||
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
>
|
||||||
>
|
<MyText style={tw`text-white font-semibold`}>Clear Filters</MyText>
|
||||||
<MyText style={tw`text-white font-semibold`}>Clear Filters</MyText>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
)}
|
||||||
)}
|
</View>
|
||||||
</View>
|
}
|
||||||
}
|
contentContainerStyle={tw`p-4`}
|
||||||
contentContainerStyle={tw`pb-4`}
|
showsVerticalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
onRefresh={handleRefresh}
|
||||||
refreshControl={
|
initialNumToRender={6}
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
maxToRenderPerBatch={8}
|
||||||
}
|
windowSize={5}
|
||||||
/>
|
removeClippedSubviews
|
||||||
</View>
|
/>
|
||||||
</AppContainer>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
>
|
>
|
||||||
{({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
|
{({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
|
||||||
return (
|
return (
|
||||||
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`pb-10`}>
|
<ScrollView style={tw`flex-1`} contentContainerStyle={tw`px-4 pt-4 pb-10`} keyboardShouldPersistTaps="handled">
|
||||||
<MyTextInput
|
<MyTextInput
|
||||||
testID="product-name-input"
|
testID="product-name-input"
|
||||||
topLabel="Product Name"
|
topLabel="Product Name"
|
||||||
|
|
@ -341,6 +341,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
</View>
|
</View>
|
||||||
{(values.variants.length > 1 || isExistingSku) && (
|
{(values.variants.length > 1 || isExistingSku) && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
testID={`variant-${vIndex}-remove`}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (isExistingSku) {
|
if (isExistingSku) {
|
||||||
// Mark existing SKU as deleted (sent to backend on save).
|
// Mark existing SKU as deleted (sent to backend on save).
|
||||||
|
|
@ -600,6 +601,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
testID="add-variant-button-footer"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
push(defaultVariant())
|
push(defaultVariant())
|
||||||
setVariantImages((prev) => [...prev, []])
|
setVariantImages((prev) => [...prev, []])
|
||||||
|
|
@ -614,6 +616,7 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||||
</FieldArray>
|
</FieldArray>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
testID="create-product-button"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const validationErrors = await validateForm()
|
const validationErrors = await validateForm()
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
|
|
||||||
115
change-log.txt
115
change-log.txt
|
|
@ -2168,3 +2168,118 @@ package.json:
|
||||||
"typecheck": "bash typecheck",
|
"typecheck": "bash typecheck",
|
||||||
- "e2e": "bash e2e/run.sh",
|
- "e2e": "bash e2e/run.sh",
|
||||||
"web-ui": "bun run --filter web-ui",
|
"web-ui": "bun run --filter web-ui",
|
||||||
|
|
||||||
|
[2026-09-15 23:29:38] Maestro E2E setup: add missing testIDs + tests/e2e/ scaffold.
|
||||||
|
First flow: add a product with 2 SKUs (one flash), values from env, taps by testID.
|
||||||
|
|
||||||
|
=== packages/ui/src/components/bottom-dropdown.tsx ===
|
||||||
|
- option row TouchableOpacity (inside filteredOptions.map):
|
||||||
|
+ testID={`${testID}-option-${option.value}`}
|
||||||
|
+ accessibilityLabel={`${testID}-option-${option.value}`}
|
||||||
|
|
||||||
|
=== apps/admin-ui/src/components/ProductForm.tsx ===
|
||||||
|
- footer "Add Variant" TouchableOpacity: + testID="add-variant-button-footer"
|
||||||
|
- variant delete/restore TouchableOpacity: + testID={`variant-${vIndex}-remove`}
|
||||||
|
- submit TouchableOpacity: + testID="create-product-button"
|
||||||
|
|
||||||
|
=== apps/admin-ui/app/(drawer)/dashboard/products/index.tsx ===
|
||||||
|
- header "Add Product" MyButton: + testID="add-product-button"
|
||||||
|
|
||||||
|
=== apps/admin-ui/app/(drawer)/dashboard/index.tsx ===
|
||||||
|
- MenuItemComponent Pressable: + testID={item.testID} + accessibilityLabel={item.testID}
|
||||||
|
|
||||||
|
=== .gitignore ===
|
||||||
|
- + tests/e2e/.env
|
||||||
|
|
||||||
|
=== NEW files under tests/e2e/ ===
|
||||||
|
- config.yaml (workspace config: flows: ['*.yaml'])
|
||||||
|
- add-product.yaml (main flow; appId ${APP_ID})
|
||||||
|
- subflows/login.yaml (reusable login)
|
||||||
|
- subflows/pick-image.yaml (OS photo-picker adapter; optional fallbacks)
|
||||||
|
- assets/test-product.png (copied from apps/admin-ui/assets/images/partial-react-logo.png)
|
||||||
|
- .env.example
|
||||||
|
- run.sh (sources .env, appends timestamp to PRODUCT_NAME, runs maestro -e)
|
||||||
|
- README.md
|
||||||
|
|
||||||
|
[2026-09-15 23:39:25] Fix run.sh .env loading (spaces in values).
|
||||||
|
|
||||||
|
Symptom: `./.env: line 19: g: command not found` — `source`-ing `.env` treated
|
||||||
|
`SKU1_QTY=500 g` as the command `g` (same for `PRODUCT_NAME=E2E Test Product`).
|
||||||
|
|
||||||
|
=== tests/e2e/run.sh ===
|
||||||
|
- replaced `set -a; . ./.env; set +a` with a line-by-line loader that tolerates
|
||||||
|
unquoted spaces:
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
line="${line%$'\r'}"
|
||||||
|
case "$line" in '' | '#'*) continue ;; esac
|
||||||
|
export "$line"
|
||||||
|
done < ./.env
|
||||||
|
- header comment updated (read, not source)
|
||||||
|
|
||||||
|
=== tests/e2e/.env.example ===
|
||||||
|
- quoted space-containing values (PRODUCT_NAME, SKU1_QTY, SKU2_QTY) so it is also
|
||||||
|
safe to `source` directly.
|
||||||
|
|
||||||
|
[2026-09-15 23:46:55] Fix add-product flow: scroll to fields + remove nested scroll on form screens.
|
||||||
|
|
||||||
|
Symptom: Maestro failed with `Element not found: Id matching regex: variant-0-price`.
|
||||||
|
Cause: ProductForm fields below the fold were never scrolled to; also add.tsx/edit.tsx
|
||||||
|
wrapped ProductForm in AppContainer (KeyboardAwareScrollView) around ProductForm's own
|
||||||
|
ScrollView — nested same-direction scrollers.
|
||||||
|
|
||||||
|
=== apps/admin-ui/app/(drawer)/dashboard/products/add.tsx ===
|
||||||
|
- import: `{ Alert }` from 'react-native' -> `{ Alert, View }`
|
||||||
|
- import: `{ AppContainer, ImageUploaderNeoPayload }` -> `{ ImageUploaderNeoPayload, tw }`
|
||||||
|
- render: `<AppContainer><ProductForm .../></AppContainer>` -> `<View style={tw`flex-1 bg-white`}>...</View>`
|
||||||
|
|
||||||
|
=== apps/admin-ui/app/(drawer)/dashboard/products/edit.tsx ===
|
||||||
|
- main render: `<AppContainer><ProductForm .../></AppContainer>` -> `<View style={tw`flex-1 bg-white`}>...</View>`
|
||||||
|
(loading/error states still use AppContainer; import kept)
|
||||||
|
|
||||||
|
=== apps/admin-ui/src/components/ProductForm.tsx ===
|
||||||
|
- ScrollView: contentContainerStyle `pb-10` -> `px-4 pt-4 pb-10` (padding moved off AppContainer)
|
||||||
|
+ keyboardShouldPersistTaps="handled"
|
||||||
|
|
||||||
|
=== tests/e2e/add-product.yaml ===
|
||||||
|
- added scrollUntilVisible (direction DOWN, UP for add-variant-button) before every
|
||||||
|
field tap so off-screen inputs are scrolled into view.
|
||||||
|
|
||||||
|
[2026-09-15 23:52:45] Harden the OS photo-picker subflow.
|
||||||
|
|
||||||
|
Symptom: the picker opened and selected an image but never pressed "Add"
|
||||||
|
(bottom-right), so it stayed open and the flow later failed on
|
||||||
|
`No visible element found: id: create-product-button`.
|
||||||
|
|
||||||
|
=== tests/e2e/subflows/pick-image.yaml ===
|
||||||
|
- confirm step now tries, in order (all optional):
|
||||||
|
text regex '.*(Add|Done|Select|Open|OK).*'
|
||||||
|
id com.google.android.providers.media.module:id/button_add
|
||||||
|
id com.google.android.providers.media.module:id/button_done
|
||||||
|
id com.google.android.documentsui:id/button_add
|
||||||
|
- added waitForAnimationToEnd after selecting the thumbnail
|
||||||
|
- added a guarded last-resort tap: only when the app root is still not visible
|
||||||
|
(picker still open) tap point '90%,92%' (bottom-right)
|
||||||
|
- added a final hard assertion `extendedWaitUntil visible id: app-root` so a
|
||||||
|
stuck picker fails here instead of confusingly at create-product-button
|
||||||
|
|
||||||
|
[2026-09-15 23:53:46] Pin the picker confirm button to its real resource id.
|
||||||
|
|
||||||
|
Confirmed from AOSP MediaProvider res/layout/activity_photo_picker.xml:
|
||||||
|
- thumbnail id: icon_thumbnail
|
||||||
|
- confirm id: button_add (Button, text "Add", bottom-right of picker_bottom_bar)
|
||||||
|
|
||||||
|
=== tests/e2e/subflows/pick-image.yaml ===
|
||||||
|
- thumbnail taps: added AOSP package fallbacks
|
||||||
|
com.google.android.providers.media.module:id/icon_thumbnail
|
||||||
|
com.android.providers.media.module:id/icon_thumbnail
|
||||||
|
com.google.android.documentsui:id/thumbnail
|
||||||
|
com.android.documentsui:id/thumbnail
|
||||||
|
- confirm taps now target the exact id first (Google + AOSP + documentsui):
|
||||||
|
...providers.media.module:id/button_add
|
||||||
|
- moved the text/coordinate fallbacks inside a `when: notVisible id: app-root`
|
||||||
|
guard so they can only run while the picker is still open (previously the text
|
||||||
|
regex could have tapped "Add Variant" in the app after the picker closed)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,8 @@ const BottomDropdown: React.FC<BottomDropdownProps> = ({
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={option.value}
|
key={option.value}
|
||||||
|
testID={`${testID}-option-${option.value}`}
|
||||||
|
accessibilityLabel={`${testID}-option-${option.value}`}
|
||||||
style={[
|
style={[
|
||||||
tw`px-4 py-3 rounded-md my-1 mx-2 flex-row items-center justify-between`,
|
tw`px-4 py-3 rounded-md my-1 mx-2 flex-row items-center justify-between`,
|
||||||
selected ? { backgroundColor: colors.brand50 } : tw`bg-transparent`,
|
selected ? { backgroundColor: colors.brand50 } : tw`bg-transparent`,
|
||||||
|
|
|
||||||
28
tests/e2e/.env.example
Normal file
28
tests/e2e/.env.example
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Copy this file to `tests/e2e/.env` and fill in real values.
|
||||||
|
# `tests/e2e/run.sh` loads it. `.env` is gitignored.
|
||||||
|
# Quote values that contain spaces (they are fine either way with run.sh).
|
||||||
|
|
||||||
|
# App under test
|
||||||
|
APP_ID=in.freshyo.adminui
|
||||||
|
|
||||||
|
# Admin login (must exist on the target backend)
|
||||||
|
STAFF_NAME=
|
||||||
|
STAFF_PASSWORD=
|
||||||
|
|
||||||
|
# Product under test. run.sh appends a timestamp so re-runs don't collide with
|
||||||
|
# the backend's unique-name check.
|
||||||
|
PRODUCT_NAME="E2E Test Product"
|
||||||
|
|
||||||
|
# Id of the store to pick in the Store dropdown (see the Stores screen / DB)
|
||||||
|
STORE_ID=1
|
||||||
|
|
||||||
|
# Variant 1 (regular SKU)
|
||||||
|
SKU1_QTY="500 g"
|
||||||
|
SKU1_PRICE=199
|
||||||
|
SKU1_MRP=249
|
||||||
|
|
||||||
|
# Variant 2 (flash SKU)
|
||||||
|
SKU2_QTY="1 kg"
|
||||||
|
SKU2_PRICE=349
|
||||||
|
SKU2_MRP=399
|
||||||
|
SKU2_FLASH_PRICE=299
|
||||||
62
tests/e2e/README.md
Normal file
62
tests/e2e/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Admin E2E tests (Maestro)
|
||||||
|
|
||||||
|
Maestro flows that drive the **admin-ui** Expo app on an Android emulator.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/e2e/
|
||||||
|
config.yaml # workspace config (entry flows = *.yaml at the root)
|
||||||
|
add-product.yaml # first flow: create a product with 2 SKUs (one flash)
|
||||||
|
subflows/
|
||||||
|
login.yaml # reusable login
|
||||||
|
pick-image.yaml # OS photo-picker adapter (Android)
|
||||||
|
assets/test-product.png # seeded into the emulator gallery via `addMedia`
|
||||||
|
run.sh # sources .env and runs `maestro test`
|
||||||
|
.env.example # copy to .env and fill in
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Maestro CLI installed (`curl -Ls "https://get.maestro.mobile.dev" | bash`).
|
||||||
|
- A booted Android emulator (`maestro start-device --platform android`, or Android Studio).
|
||||||
|
- The admin-ui **dev build** installed on the emulator (`bun run --filter admin-ui android`, appId `in.freshyo.adminui`).
|
||||||
|
- The build must be able to reach the backend (login + image upload hit the API/R2).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp tests/e2e/.env.example tests/e2e/.env
|
||||||
|
# edit tests/e2e/.env — at minimum STAFF_NAME, STAFF_PASSWORD, STORE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tests/e2e/run.sh # every entry flow in tests/e2e/
|
||||||
|
bash tests/e2e/run.sh add-product.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`run.sh` appends a timestamp to `PRODUCT_NAME` (the backend rejects duplicate names).
|
||||||
|
|
||||||
|
## What `add-product.yaml` does
|
||||||
|
|
||||||
|
1. `addMedia` seeds `assets/test-product.png` into the emulator gallery.
|
||||||
|
2. `launchApp` with `clearState: true` (forces login) and gallery permission.
|
||||||
|
3. Logs in (subflow) and taps the `add-product-menu-item` quick action.
|
||||||
|
4. Fills the product name and selects Store + Product Type.
|
||||||
|
5. Variant 1: quantity, price, MRP, attaches an image.
|
||||||
|
6. Adds Variant 2, fills quantity/prices, toggles **Flash Available**, sets flash price, attaches an image.
|
||||||
|
7. Submits and asserts `Product created successfully!`.
|
||||||
|
|
||||||
|
All in-app interactions use `testID` selectors (`id:`). The only exception is the
|
||||||
|
OS photo picker, which is system UI and is targeted by its platform IDs in
|
||||||
|
`subflows/pick-image.yaml`.
|
||||||
|
|
||||||
|
## Notes / gotchas
|
||||||
|
|
||||||
|
- The image picker flow is the most version-sensitive part; thumbs are targeted by
|
||||||
|
the Android 13/14 Photo Picker id with a DocumentsUI fallback, and the confirm
|
||||||
|
button by text regex `Add|Done|Select`.
|
||||||
|
- On a reused emulator, `addMedia` images accumulate in the gallery (harmless).
|
||||||
|
- Tests create real data on the connected backend.
|
||||||
169
tests/e2e/add-product.yaml
Normal file
169
tests/e2e/add-product.yaml
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
appId: ${APP_ID || "in.freshyo.adminui"}
|
||||||
|
env:
|
||||||
|
# Values are supplied via `-e` by run.sh. The inline `||` defaults let the
|
||||||
|
# flow run standalone against a dev build without run.sh.
|
||||||
|
APP_ID: ${APP_ID || "in.freshyo.adminui"}
|
||||||
|
STAFF_NAME: ${STAFF_NAME || ""}
|
||||||
|
STAFF_PASSWORD: ${STAFF_PASSWORD || ""}
|
||||||
|
PRODUCT_NAME: ${PRODUCT_NAME || "E2E Test Product"}
|
||||||
|
STORE_ID: ${STORE_ID || 1}
|
||||||
|
SKU1_QTY: ${SKU1_QTY || "500 g"}
|
||||||
|
SKU1_PRICE: ${SKU1_PRICE || "199"}
|
||||||
|
SKU1_MRP: ${SKU1_MRP || "249"}
|
||||||
|
SKU2_QTY: ${SKU2_QTY || "1 kg"}
|
||||||
|
SKU2_PRICE: ${SKU2_PRICE || "349"}
|
||||||
|
SKU2_MRP: ${SKU2_MRP || "399"}
|
||||||
|
SKU2_FLASH_PRICE: ${SKU2_FLASH_PRICE || "299"}
|
||||||
|
tags:
|
||||||
|
- products
|
||||||
|
- smoke
|
||||||
|
---
|
||||||
|
# Seed the emulator gallery so the OS picker has an image to select.
|
||||||
|
- addMedia:
|
||||||
|
- './assets/test-product.png'
|
||||||
|
|
||||||
|
# Fresh state (logs out) + grant gallery access.
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
permissions:
|
||||||
|
storage: allow
|
||||||
|
|
||||||
|
# --- Login ---
|
||||||
|
- runFlow: subflows/login.yaml
|
||||||
|
|
||||||
|
# --- Navigate to Add Product ---
|
||||||
|
- tapOn:
|
||||||
|
id: add-product-menu-item
|
||||||
|
- assertVisible:
|
||||||
|
id: product-name-input
|
||||||
|
|
||||||
|
# --- Product level ---
|
||||||
|
- tapOn:
|
||||||
|
id: product-name-input
|
||||||
|
- inputText: ${PRODUCT_NAME}
|
||||||
|
- hideKeyboard
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: product-store-dropdown
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: product-store-dropdown
|
||||||
|
- tapOn:
|
||||||
|
id: product-store-dropdown-option-${STORE_ID}
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: product-type-dropdown
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: product-type-dropdown
|
||||||
|
- tapOn:
|
||||||
|
id: product-type-dropdown-option-item
|
||||||
|
|
||||||
|
# --- Variant 1 (regular) ---
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-0-attr-0-value
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-0-attr-0-value
|
||||||
|
- inputText: ${SKU1_QTY}
|
||||||
|
- hideKeyboard
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-0-price
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-0-price
|
||||||
|
- inputText: ${SKU1_PRICE}
|
||||||
|
- hideKeyboard
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-0-market-price
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-0-market-price
|
||||||
|
- inputText: ${SKU1_MRP}
|
||||||
|
- hideKeyboard
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-0-image-add
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-0-image-add
|
||||||
|
- runFlow: subflows/pick-image.yaml
|
||||||
|
|
||||||
|
# --- Add variant 2 (flash) ---
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: add-variant-button
|
||||||
|
direction: UP
|
||||||
|
- tapOn:
|
||||||
|
id: add-variant-button
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-1-attr-0-value
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-1-attr-0-value
|
||||||
|
- inputText: ${SKU2_QTY}
|
||||||
|
- hideKeyboard
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-1-price
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-1-price
|
||||||
|
- inputText: ${SKU2_PRICE}
|
||||||
|
- hideKeyboard
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-1-market-price
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-1-market-price
|
||||||
|
- inputText: ${SKU2_MRP}
|
||||||
|
- hideKeyboard
|
||||||
|
|
||||||
|
# Mark variant 2 as flash and set its flash price.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-1-flash
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-1-flash
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-1-flash-price
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-1-flash-price
|
||||||
|
- inputText: ${SKU2_FLASH_PRICE}
|
||||||
|
- hideKeyboard
|
||||||
|
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: variant-1-image-add
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: variant-1-image-add
|
||||||
|
- runFlow: subflows/pick-image.yaml
|
||||||
|
|
||||||
|
# --- Submit ---
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: create-product-button
|
||||||
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: create-product-button
|
||||||
|
|
||||||
|
- assertVisible: 'Product created successfully!'
|
||||||
|
- tapOn: 'OK'
|
||||||
BIN
tests/e2e/assets/test-product.png
Executable file
BIN
tests/e2e/assets/test-product.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 5 KiB |
6
tests/e2e/config.yaml
Normal file
6
tests/e2e/config.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Maestro workspace config for the root E2E suite.
|
||||||
|
# Only YAML files in this folder are entry flows; `subflows/` is invoked
|
||||||
|
# explicitly via runFlow and is intentionally not matched here.
|
||||||
|
flows:
|
||||||
|
- '*.yaml'
|
||||||
|
- '!config.yaml'
|
||||||
57
tests/e2e/run.sh
Executable file
57
tests/e2e/run.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Runs the Maestro E2E suite against a running Android emulator.
|
||||||
|
#
|
||||||
|
# Values come from tests/e2e/.env (copy .env.example first). Maestro's CLI does
|
||||||
|
# not load .env files itself, so we read it here and forward each value with `-e`.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash tests/e2e/run.sh # run every entry flow in tests/e2e/
|
||||||
|
# bash tests/e2e/run.sh add-product.yaml
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Missing tests/e2e/.env — copy .env.example to .env and fill it in." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load .env line-by-line instead of `source`-ing it, so values that contain
|
||||||
|
# spaces (e.g. `SKU1_QTY=500 g`) work without needing quotes.
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
line="${line%$'\r'}" # tolerate CRLF
|
||||||
|
case "$line" in
|
||||||
|
'' | '#'*) continue ;;
|
||||||
|
esac
|
||||||
|
export "$line"
|
||||||
|
done < ./.env
|
||||||
|
|
||||||
|
: "${APP_ID:?APP_ID is required}"
|
||||||
|
: "${STAFF_NAME:?STAFF_NAME is required}"
|
||||||
|
: "${STAFF_PASSWORD:?STAFF_PASSWORD is required}"
|
||||||
|
: "${PRODUCT_NAME:?PRODUCT_NAME is required}"
|
||||||
|
: "${STORE_ID:?STORE_ID is required}"
|
||||||
|
|
||||||
|
# Unique product name so repeat runs don't trip the backend's duplicate check.
|
||||||
|
PRODUCT_NAME="${PRODUCT_NAME} $(date +%s)"
|
||||||
|
export PRODUCT_NAME
|
||||||
|
|
||||||
|
# Default to the whole suite when no path/tags are passed.
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
set -- .
|
||||||
|
fi
|
||||||
|
|
||||||
|
maestro test \
|
||||||
|
-e APP_ID="$APP_ID" \
|
||||||
|
-e STAFF_NAME="$STAFF_NAME" \
|
||||||
|
-e STAFF_PASSWORD="$STAFF_PASSWORD" \
|
||||||
|
-e PRODUCT_NAME="$PRODUCT_NAME" \
|
||||||
|
-e STORE_ID="$STORE_ID" \
|
||||||
|
-e SKU1_QTY="${SKU1_QTY:-}" \
|
||||||
|
-e SKU1_PRICE="${SKU1_PRICE:-}" \
|
||||||
|
-e SKU1_MRP="${SKU1_MRP:-}" \
|
||||||
|
-e SKU2_QTY="${SKU2_QTY:-}" \
|
||||||
|
-e SKU2_PRICE="${SKU2_PRICE:-}" \
|
||||||
|
-e SKU2_MRP="${SKU2_MRP:-}" \
|
||||||
|
-e SKU2_FLASH_PRICE="${SKU2_FLASH_PRICE:-}" \
|
||||||
|
"$@"
|
||||||
20
tests/e2e/subflows/login.yaml
Normal file
20
tests/e2e/subflows/login.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
appId: ${APP_ID || "in.freshyo.adminui"}
|
||||||
|
env:
|
||||||
|
STAFF_NAME: ${STAFF_NAME || ""}
|
||||||
|
STAFF_PASSWORD: ${STAFF_PASSWORD || ""}
|
||||||
|
---
|
||||||
|
# Logs into the admin app. Reused by other flows via `runFlow: subflows/login.yaml`.
|
||||||
|
- assertVisible:
|
||||||
|
id: login-name-input
|
||||||
|
- tapOn:
|
||||||
|
id: login-name-input
|
||||||
|
- inputText: ${STAFF_NAME}
|
||||||
|
- tapOn:
|
||||||
|
id: login-password-input
|
||||||
|
- inputText: ${STAFF_PASSWORD}
|
||||||
|
- hideKeyboard
|
||||||
|
- tapOn:
|
||||||
|
id: login-button
|
||||||
|
# Dashboard quick actions are only rendered once the drawer shell is up.
|
||||||
|
- assertVisible:
|
||||||
|
id: add-product-menu-item
|
||||||
79
tests/e2e/subflows/pick-image.yaml
Normal file
79
tests/e2e/subflows/pick-image.yaml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
appId: ${APP_ID || "in.freshyo.adminui"}
|
||||||
|
---
|
||||||
|
# Android system picker adapter.
|
||||||
|
#
|
||||||
|
# The picker is OS-level UI in a separate window, so it cannot be targeted with
|
||||||
|
# app testIDs. Selectors here are system ids/text, and every step is `optional`
|
||||||
|
# so different OS versions fall through until one matches.
|
||||||
|
#
|
||||||
|
# Confirmed from AOSP MediaProvider (activity_photo_picker.xml):
|
||||||
|
# thumbnail : @id/icon_thumbnail
|
||||||
|
# confirm : @id/button_add (Button, text "Add", bottom-right)
|
||||||
|
# The provider package is `com.google.android.providers.media.module` on Google
|
||||||
|
# Play images and `com.android.providers.media.module` on AOSP images.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
platform: Android
|
||||||
|
commands:
|
||||||
|
# Runtime permission prompt (older Android / first launch).
|
||||||
|
- tapOn:
|
||||||
|
text: 'Allow'
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Select the first thumbnail: Android 13/14 Photo Picker, then the older
|
||||||
|
# DocumentsUI gallery.
|
||||||
|
- tapOn:
|
||||||
|
id: 'com.google.android.providers.media.module:id/icon_thumbnail'
|
||||||
|
index: 0
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
id: 'com.android.providers.media.module:id/icon_thumbnail'
|
||||||
|
index: 0
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
id: 'com.google.android.documentsui:id/thumbnail'
|
||||||
|
index: 0
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
id: 'com.android.documentsui:id/thumbnail'
|
||||||
|
index: 0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd
|
||||||
|
|
||||||
|
# Confirm the selection by its exact id.
|
||||||
|
- tapOn:
|
||||||
|
id: 'com.google.android.providers.media.module:id/button_add'
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
id: 'com.android.providers.media.module:id/button_add'
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
id: 'com.google.android.documentsui:id/button_add'
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Give the picker a moment to close.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: app-root
|
||||||
|
timeout: 3000
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Fallbacks run ONLY while the picker is still open (app root not visible),
|
||||||
|
# so they can never tap in-app controls like "Add Variant".
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible:
|
||||||
|
id: app-root
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: '.*(Add|Done|Select|Open|OK).*'
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
point: '90%,92%'
|
||||||
|
|
||||||
|
# The picker must be closed before the rest of the flow can continue.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: app-root
|
||||||
|
timeout: 15000
|
||||||
Loading…
Add table
Reference in a new issue