diff --git a/custom-products-catalog/template/.yarnrc.yml b/custom-products-catalog/template/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/custom-products-catalog/template/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/custom-products-catalog/template/package.json.ejs b/custom-products-catalog/template/package.json.ejs index 5642552..7a45983 100644 --- a/custom-products-catalog/template/package.json.ejs +++ b/custom-products-catalog/template/package.json.ejs @@ -5,12 +5,12 @@ to: package.json "name": "<%= packageName %>", "version": "1.0.0", "dependencies": { - "@tanstack/react-query": "^4.36.1", "@wix/dashboard-react": "^1.0.4", "@wix/sdk-react": "^0.3.8", "@wix/design-system": "^1.0.0", "@wix/stores": "^1.0.120", "@wix/wix-ui-icons-common": "^3.0.34" + "@wix/patterns": "^1.3.0", }, "devDependencies": { "@types/react": "^16.0.0", diff --git a/custom-products-catalog/template/src/dashboard/components/create-product.tsx b/custom-products-catalog/template/src/dashboard/components/create-product.tsx index 2fc18d3..fb8e993 100644 --- a/custom-products-catalog/template/src/dashboard/components/create-product.tsx +++ b/custom-products-catalog/template/src/dashboard/components/create-product.tsx @@ -1,20 +1,20 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { - Button, Modal, CustomModalLayout, FormField, Input, - Loader, } from '@wix/design-system'; import '@wix/design-system/styles.global.css'; -import * as Icons from '@wix/wix-ui-icons-common'; -import { useCreateProduct } from '../hooks/stores'; -export function CreateProduct() { - const createProduct = useCreateProduct(); +export function CreateProductModal({ showModal, onSave }: { showModal: boolean, onSave: (name: string) => void }) { const [productName, setProductName] = useState(''); - const [shown, setShown] = useState(false); + const [shown, setShown] = useState(showModal); + + useEffect(() => { + setShown(showModal); + }, [showModal]) + const toggleModal = () => { setShown(!shown); @@ -22,39 +22,34 @@ export function CreateProduct() { }; return ( - <> - - - : 'Save', - }} - primaryButtonOnClick={async () => { - await createProduct.mutateAsync({ product: { name: productName } }); - toggleModal(); - }} - secondaryButtonText="Cancel" - secondaryButtonOnClick={toggleModal} - onCloseButtonClick={toggleModal} - content={ - - setProductName(e.currentTarget.value)} - /> - - } - /> - - + + { + onSave(productName) + setProductName('') + }} + secondaryButtonText="Cancel" + secondaryButtonOnClick={toggleModal} + onCloseButtonClick={toggleModal} + content={ + + setProductName(e.currentTarget.value)} + /> + + } + /> + ); } diff --git a/custom-products-catalog/template/src/dashboard/hooks/stores.ts b/custom-products-catalog/template/src/dashboard/hooks/stores.ts index a19f101..59e7647 100644 --- a/custom-products-catalog/template/src/dashboard/hooks/stores.ts +++ b/custom-products-catalog/template/src/dashboard/hooks/stores.ts @@ -1,89 +1,59 @@ import { products } from '@wix/stores'; import { useWixModules } from '@wix/sdk-react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDashboard } from '@wix/dashboard-react'; +import { useCallback } from 'react'; +import { CollectionOptimisticActions } from '@wix/patterns'; -export function useProductsQuery() { - const { queryProducts } = useWixModules(products); - return useQuery({ - queryKey: ['products'], - refetchOnWindowFocus: false, - queryFn: async () => { - const { items } = await queryProducts().descending('lastUpdated').find(); - return items; - }, - }); -} +export function useCreateProduct(optimisticActions: CollectionOptimisticActions) { + const {createProduct} = useWixModules(products); -export function useCreateProduct() { - const queryClient = useQueryClient(); - const { showToast } = useDashboard(); - const { createProduct } = useWixModules(products); + return useCallback((productName: string) => { + const newProduct: products.Product = { + _id: Date().toString(), + name: productName, + _createdDate: new Date(), + lastUpdated: new Date(), + productType: products.ProductType.physical, + description: 'New Product Description', + priceData: { + currency: 'USD', + price: 10, + }, + }; + optimisticActions.createOne(newProduct, { + submit: async (products: products.Product[]) => { + const createdProduct = products[0] + const response = await createProduct(createdProduct); + return response.product ? [response.product] : []; + }, + successToast: { + message: `${newProduct.name} was successfully created`, + type: 'SUCCESS', + }, + errorToast: () => 'Failed to create product', + }) + }, [optimisticActions, createProduct]); - return useMutation({ - mutationKey: ['createProduct'], - mutationFn: (createProductRequest?: products.CreateProductRequest) => { - return createProduct({ - name: 'New Product', - description: 'New Product Description', - priceData: { - currency: 'USD', - price: 10, - }, - productType: products.ProductType.physical, - ...createProductRequest?.product, - }); - }, - onError: () => - showToast({ message: 'Failed to create product', type: 'error' }), - onSuccess: ({ product }) => { - queryClient.setQueryData( - ['products'], - (oldProducts = []) => - product ? [product, ...oldProducts] : oldProducts - ); - showToast({ - message: 'Product created successfully', - type: 'success', - }); - }, - }); } -export function useDeleteProducts({ - productIdsToDelete, - onSuccess, -}: { - productIdsToDelete: Set; - onSuccess?: () => void; -}) { - const queryClient = useQueryClient(); - const { showToast } = useDashboard(); +export function useDeleteProducts(optimisticActions: CollectionOptimisticActions) { const { deleteProduct } = useWixModules(products); - return useMutation({ - mutationKey: ['deleteProducts'], - mutationFn: () => - Promise.all( - [...productIdsToDelete].map((productId) => deleteProduct(productId)) + return useCallback((productsToDelete: products.Product[] ) => { + optimisticActions.deleteMany(productsToDelete, { + submit: async (deletedProducts: products.Product[]) => ( + await Promise.all( + deletedProducts.map((product) => deleteProduct(product._id!)) + ) ), - onError: () => - showToast({ message: 'Failed to delete products', type: 'error' }), - onSuccess: () => { - queryClient.setQueryData( - ['products'], - (oldProducts = []) => - oldProducts.filter( - (product: products.Product) => !productIdsToDelete.has(product._id!) - ) - ); - showToast({ + successToast: { message: `${ - productIdsToDelete.size > 1 ? 'Products' : 'Product' + productsToDelete.length > 1 ? 'Products' : 'Product' } deleted successfully`, - type: 'success', - }); - onSuccess?.(); - }, - }); + type: 'SUCCESS', + }, + errorToast: () => `Failed to delete ${ + productsToDelete.length > 1 ? 'Products' : 'Product' + }`, + }); + }, [optimisticActions, deleteProduct]); } diff --git a/custom-products-catalog/template/src/dashboard/pages/page.tsx b/custom-products-catalog/template/src/dashboard/pages/page.tsx index 87c642f..3cf20b6 100644 --- a/custom-products-catalog/template/src/dashboard/pages/page.tsx +++ b/custom-products-catalog/template/src/dashboard/pages/page.tsx @@ -1,160 +1,283 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { - Button, - Page, - Table, Box, Text, - TextButton, - Card, - TableToolbar, Image, - Checkbox, - Loader, - EmptyState, + Breadcrumbs } from '@wix/design-system'; import '@wix/design-system/styles.global.css'; -import * as Icons from '@wix/wix-ui-icons-common'; -import type { products } from '@wix/stores'; +import { products } from '@wix/stores'; import { withProviders } from '../withProviders'; -import { useDeleteProducts, useProductsQuery } from '../hooks/stores'; -import { CreateProduct } from '../components/create-product'; -import EmptyStateServerError from '../svg/EmptyState_ServerError.svg'; +import { useCreateProduct, useDeleteProducts } from '../hooks/stores'; +import { CreateProductModal } from '../components/create-product'; +import { CollectionPage } from '@wix/patterns/page'; +import { + useTableCollection, + Table, + PrimaryPageButton, + useOptimisticActions, + deleteSecondaryAction, + MultiBulkActionToolbar, + CustomColumns, + Filter, + CollectionToolbarFilters, + dateRangeFilter, + RangeItem, + DateRangeFilter, + RadioGroupFilter, + stringsArrayFilter, +} from '@wix/patterns' +import { useWixModules } from '@wix/sdk-react'; + +type TableFilters = { + productType: Filter; + lastUpdated: Filter>; +} + +type SupportedQueryFields = Parameters[0] | Parameters[0] + +const productTypeToDisplayName: {[key in products.ProductType] : string | undefined} = { + [products.ProductType.physical]: 'Physical', + [products.ProductType.digital]: 'Digital', + [products.ProductType.unspecified_product_type]: undefined +} function Products() { - const [productIdsToDelete, setProductIdsToDelete] = useState>( - new Set() - ); - const products = useProductsQuery(); + const [shown, setShown] = useState(false); + const { queryProducts, deleteProduct } = useWixModules(products); + const tableState = useTableCollection({ + queryName: 'products-catalog', + itemKey: (product: products.Product) => product._id!, + itemName: (contact: products.Product) => contact.name!, + limit: 20, + fetchData: (query) => { + const { limit, offset, search, sort, filters } = query; + let queryBuilder = queryProducts().limit(limit).skip(offset); + + if (search) { + queryBuilder = queryBuilder.startsWith("name", search) + } + + if (filters) { + const { productType, lastUpdated } = filters + + if (productType) { + queryBuilder = queryBuilder.in('productType', productType) + } - const deleteProducts = useDeleteProducts({ - productIdsToDelete, - onSuccess: () => setProductIdsToDelete(new Set()), + if (lastUpdated) { + if (lastUpdated.from) { + queryBuilder = queryBuilder + .gt('lastUpdated', lastUpdated.from) + } + + if (lastUpdated.to) { + queryBuilder = queryBuilder + .lt('lastUpdated', lastUpdated.to); + } + } + } + + if (sort) { + sort.forEach(s => { + const fieldName = s.fieldName as SupportedQueryFields; + if (s.order === 'asc') { + queryBuilder = queryBuilder.ascending(fieldName); + } else if (s.order === 'desc') { + queryBuilder = queryBuilder.descending(fieldName); + } + }); + } + + return queryBuilder.find().then(({ items = [], totalCount: total }) => { + return { + items, + total, + }; + }); + }, + fetchErrorMessage: () => 'Error fetching products', + filters: { + lastUpdated: dateRangeFilter(), + productType: stringsArrayFilter({ itemName: (p) => productTypeToDisplayName[p] ?? p }) + }, }); - const addProductToDelete = async (productId: string) => { - if (productIdsToDelete.has(productId)) { - productIdsToDelete.delete(productId); - setProductIdsToDelete(new Set(productIdsToDelete)); - } else { - productIdsToDelete.add(productId); - setProductIdsToDelete(new Set(productIdsToDelete)); - } - }; - - const columns = useMemo( - () => [ - { - title: '', - width: '20px', - render: (row: products.Product) => ( - - addProductToDelete(row._id!)} - /> - - ), - }, - { - title: '', - width: '72px', - render: (row: products.Product) => ( - - ), - }, - { - title: 'Name', - render: (row: products.Product) => ( - - - {row.name} - - - {row.description} - - - ), - width: '40%', - }, - { - title: 'Price', - render: (row: products.Product) => `$${row.priceData?.price}`, - width: '15%', - }, - { - title: 'Type', - render: (row: products.Product) => row.productType, - width: '15%', - }, - ], - [productIdsToDelete, addProductToDelete] - ); + const optimisticActions = useOptimisticActions(tableState.collection, { + orderBy: () => [], + predicate: ({ search, filters }) => { + return (product) => { + if (search && !product.name?.startsWith(search)) { + return false; + } + + if (filters.productType && product.productType && filters.productType.indexOf(product.productType) === -1) { + return false; + } + + if (filters.lastUpdated && product.lastUpdated) { + const from = filters.lastUpdated.from + const to = filters.lastUpdated.to + const productLastUpdated = (new Date(product.lastUpdated)).getTime() + + if (from && productLastUpdated < from.getTime()) { + return false + } + + if (to && productLastUpdated > to.getTime()) { + return false + } + } + + return true; + } + }, + }); + + const createProduct = useCreateProduct(optimisticActions); + const deleteProducts = useDeleteProducts(optimisticActions); return ( - - } + + + } + primaryAction={ + setShown(!shown)} + /> + } /> - - {products.isLoading ? ( - - - - ) : products.isError ? ( - + + { + createProduct(productName) + setShown(false) + }}/> + + + + + } + bulkActionToolbar={({ selectedValues, openConfirmModal }) => { + const disabled = selectedValues.length > 20; + return ( + { + openConfirmModal({ + theme: 'destructive', + primaryButtonOnClick: () => { + deleteProducts(selectedValues) + }, + }); + }, + }, + ]} + />) + }} + customColumns={} + columns={[ + { + id: 'avatar', + name: 'Avatar', + title: '', + width: '72px', + render: (product) => , + reorderDisabled: true, + hiddenFromCustomColumnsSelection: true + }, + { + id: 'name', + title: 'Product / Description', + render: (row: products.Product) => ( + + + {row.name} + + + {row.description} + + + ), + width: 'auto', + reorderDisabled: true, + hideable: false + }, + { + id: 'price', + title: 'Price', + render: (row: products.Product) => `$${row.priceData?.price}`, + width: '100px', + sortable: true, + }, + { + id: 'type', + title: 'Type', + render: (row: products.Product) => { + if (!row.productType) { + return '' + } + + return productTypeToDisplayName[row.productType] ?? row.productType + }, + width: '100px', + }, + { + id: 'last-updated', + title: 'Last Updated', + render: (row: products.Product) => row.lastUpdated, + width: '200px', + defaultHidden: true, + }, + ]} + actionCell={(_product, _index, actionCellAPI) => ({ + secondaryActions: [ + deleteSecondaryAction({ + optimisticActions, + actionCellAPI, + submit: (products: products.Product[]) => ( + Promise.all( + products.map((product: products.Product) => deleteProduct(product._id!)) + ) + ), + successToast: { + message: `${_product.name} deleted successfully.`, + type: 'SUCCESS', + }, + errorToast: () => 'Product deletion failed.', + }), + ] } - title="We couldn't load this page" - // You likely didn't added required permissions or installed stores app in your site, please check the template documentation for more information. - subtitle={"Looks like there was a technical issue"} - > - products.refetch()} - prefixIcon={} - > - Try Again - - - ) : ( -
- - - - - - - {productIdsToDelete.size > 0 - ? `${productIdsToDelete.size}/${products.data?.length} selected` - : `${products.data?.length || 0} products`} - - - - {productIdsToDelete.size > 0 && ( - - - - - - )} - - - - - -
- )} -
-
+ )} + /> + + ); } diff --git a/custom-products-catalog/template/src/dashboard/svg/EmptyState_ServerError.svg b/custom-products-catalog/template/src/dashboard/svg/EmptyState_ServerError.svg deleted file mode 100644 index ab26ffc..0000000 --- a/custom-products-catalog/template/src/dashboard/svg/EmptyState_ServerError.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/custom-products-catalog/template/src/dashboard/withProviders.tsx b/custom-products-catalog/template/src/dashboard/withProviders.tsx index f585430..3e0de21 100644 --- a/custom-products-catalog/template/src/dashboard/withProviders.tsx +++ b/custom-products-catalog/template/src/dashboard/withProviders.tsx @@ -1,17 +1,15 @@ import React from 'react'; -import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { WixDesignSystemProvider } from '@wix/design-system'; import { withDashboard } from '@wix/dashboard-react'; - -const queryClient = new QueryClient(); +import { WixPatternsProvider } from '@wix/patterns/provider'; export function withProviders

(Component: React.FC

) { return withDashboard(function DashboardProviders(props: P) { return ( - - + + - + ); });