diff --git a/CHANGELOG.md b/CHANGELOG.md index 76af0541f..77d8f7beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable, unreleased changes to this project will be documented in this file. - Add secret fields in plugin configuration - #246 by @dominik-zeglen - Fix subcategories pagination - #249 by @dominik-zeglen - Update customer's details page design - #248 by @dominik-zeglen +- Use Apollo Hooks - #254 by @dominik-zeglen ## 2.0.0 diff --git a/package.json b/package.json index e0895b046..31d82bf87 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "moment-timezone": "^0.5.26", "qs": "^6.9.0", "react": "^16.9.0", - "react-apollo": "^3.0.0", + "react-apollo": "^3.1.3", "react-dom": "^16.9.0", "react-dropzone": "^8.2.0", "react-error-boundary": "^1.2.5", diff --git a/src/auth/components/SectionRoute.tsx b/src/auth/components/SectionRoute.tsx index 840ae2f89..f1b2d1546 100644 --- a/src/auth/components/SectionRoute.tsx +++ b/src/auth/components/SectionRoute.tsx @@ -1,10 +1,6 @@ import React from "react"; -import ErrorBoundary from "react-error-boundary"; import { Route, RouteProps } from "react-router-dom"; -import AppLayout from "@saleor/components/AppLayout"; -import ErrorPage from "@saleor/components/ErrorPage"; -import useNavigator from "@saleor/hooks/useNavigator"; import useUser from "@saleor/hooks/useUser"; import NotFound from "../../NotFound"; import { PermissionEnum } from "../../types/globalTypes"; @@ -18,7 +14,6 @@ export const SectionRoute: React.FC = ({ permissions, ...props }) => { - const navigate = useNavigator(); const { user } = useUser(); const hasPermissions = @@ -26,18 +21,7 @@ export const SectionRoute: React.FC = ({ permissions .map(permission => hasPermission(permission, user)) .reduce((prev, curr) => prev && curr); - return hasPermissions ? ( - - navigate("/")} />} - key={permissions ? permissions.join(":") : "home"} - > - - - - ) : ( - - ); + return hasPermissions ? : ; }; SectionRoute.displayName = "Route"; export default SectionRoute; diff --git a/src/categories/mutations.ts b/src/categories/mutations.ts index 715aa4bbf..7995b3c6f 100644 --- a/src/categories/mutations.ts +++ b/src/categories/mutations.ts @@ -1,6 +1,6 @@ import gql from "graphql-tag"; -import { TypedMutation } from "../mutations"; +import makeMutation from "@saleor/hooks/makeMutation"; import { categoryDetailsFragment } from "./queries"; import { CategoryBulkDelete, @@ -29,7 +29,7 @@ export const categoryDeleteMutation = gql` } } `; -export const TypedCategoryDeleteMutation = TypedMutation< +export const useCategoryDeleteMutation = makeMutation< CategoryDelete, CategoryDeleteVariables >(categoryDeleteMutation); @@ -48,7 +48,7 @@ export const categoryCreateMutation = gql` } } `; -export const TypedCategoryCreateMutation = TypedMutation< +export const useCategoryCreateMutation = makeMutation< CategoryCreate, CategoryCreateVariables >(categoryCreateMutation); @@ -67,7 +67,7 @@ export const categoryUpdateMutation = gql` } } `; -export const TypedCategoryUpdateMutation = TypedMutation< +export const useCategoryUpdateMutation = makeMutation< CategoryUpdate, CategoryUpdateVariables >(categoryUpdateMutation); @@ -82,7 +82,7 @@ export const categoryBulkDeleteMutation = gql` } } `; -export const TypedCategoryBulkDeleteMutation = TypedMutation< +export const useCategoryBulkDeleteMutation = makeMutation< CategoryBulkDelete, CategoryBulkDeleteVariables >(categoryBulkDeleteMutation); diff --git a/src/categories/queries.ts b/src/categories/queries.ts index 4bad812fe..d8308b92d 100644 --- a/src/categories/queries.ts +++ b/src/categories/queries.ts @@ -1,6 +1,7 @@ import gql from "graphql-tag"; -import { pageInfoFragment, TypedQuery } from "../queries"; +import makeQuery from "@saleor/hooks/makeQuery"; +import { pageInfoFragment } from "../queries"; import { CategoryDetails, CategoryDetailsVariables @@ -65,7 +66,7 @@ export const rootCategories = gql` } } `; -export const TypedRootCategoriesQuery = TypedQuery( +export const useRootCategoriesQuery = makeQuery( rootCategories ); @@ -119,7 +120,7 @@ export const categoryDetails = gql` } } `; -export const TypedCategoryDetailsQuery = TypedQuery< +export const useCategoryDetailsQuery = makeQuery< CategoryDetails, CategoryDetailsVariables >(categoryDetails); diff --git a/src/categories/views/CategoryCreate.tsx b/src/categories/views/CategoryCreate.tsx index 90d1df35d..592552c00 100644 --- a/src/categories/views/CategoryCreate.tsx +++ b/src/categories/views/CategoryCreate.tsx @@ -6,7 +6,7 @@ import useNavigator from "@saleor/hooks/useNavigator"; import useNotifier from "@saleor/hooks/useNotifier"; import { getMutationState, maybe } from "../../misc"; import CategoryCreatePage from "../components/CategoryCreatePage"; -import { TypedCategoryCreateMutation } from "../mutations"; +import { useCategoryCreateMutation } from "../mutations"; import { CategoryCreate } from "../types/CategoryCreate"; import { categoryListUrl, categoryUrl } from "../urls"; @@ -31,55 +31,54 @@ export const CategoryCreateView: React.FC = ({ navigate(categoryUrl(data.categoryCreate.category.id)); } }; + + const [createCategory, createCategoryResult] = useCategoryCreateMutation({ + onCompleted: handleSuccess + }); + + const errors = maybe( + () => createCategoryResult.data.categoryCreate.errors, + [] + ); + + const formTransitionState = getMutationState( + createCategoryResult.called, + createCategoryResult.loading, + errors + ); + return ( - - {(createCategory, createCategoryResult) => { - const errors = maybe( - () => createCategoryResult.data.categoryCreate.errors, - [] - ); - - const formTransitionState = getMutationState( - createCategoryResult.called, - createCategoryResult.loading, - errors - ); - - return ( - <> - - - navigate(parentId ? categoryUrl(parentId) : categoryListUrl()) - } - onSubmit={formData => - createCategory({ - variables: { - input: { - descriptionJson: JSON.stringify(formData.description), - name: formData.name, - seo: { - description: formData.seoDescription, - title: formData.seoTitle - } - }, - parent: parentId || null - } - }) - } - /> - - ); - }} - + <> + + + navigate(parentId ? categoryUrl(parentId) : categoryListUrl()) + } + onSubmit={formData => + createCategory({ + variables: { + input: { + descriptionJson: JSON.stringify(formData.description), + name: formData.name, + seo: { + description: formData.seoDescription, + title: formData.seoTitle + } + }, + parent: parentId || null + } + }) + } + /> + ); }; export default CategoryCreateView; diff --git a/src/categories/views/CategoryDetails.tsx b/src/categories/views/CategoryDetails.tsx index 8a4cf6002..5c6aa7d4d 100644 --- a/src/categories/views/CategoryDetails.tsx +++ b/src/categories/views/CategoryDetails.tsx @@ -24,11 +24,11 @@ import { CategoryUpdatePage } from "../components/CategoryUpdatePage/CategoryUpdatePage"; import { - TypedCategoryBulkDeleteMutation, - TypedCategoryDeleteMutation, - TypedCategoryUpdateMutation + useCategoryBulkDeleteMutation, + useCategoryDeleteMutation, + useCategoryUpdateMutation } from "../mutations"; -import { TypedCategoryDetailsQuery } from "../queries"; +import { useCategoryDetailsQuery } from "../queries"; import { CategoryBulkDelete } from "../types/CategoryBulkDelete"; import { CategoryDelete } from "../types/CategoryDelete"; import { CategoryUpdate } from "../types/CategoryUpdate"; @@ -63,6 +63,13 @@ export const CategoryDetails: React.FC = ({ ); const intl = useIntl(); + const paginationState = createPaginationState(PAGINATE_BY, params); + const { data, loading, refetch } = useCategoryDetailsQuery({ + displayLoader: true, + require: ["category"], + variables: { ...paginationState, id } + }); + const handleCategoryDelete = (data: CategoryDelete) => { if (data.categoryDelete.errors.length === 0) { notify({ @@ -73,6 +80,11 @@ export const CategoryDetails: React.FC = ({ navigate(categoryListUrl()); } }; + + const [deleteCategory, deleteResult] = useCategoryDeleteMutation({ + onCompleted: handleCategoryDelete + }); + const handleCategoryUpdate = (data: CategoryUpdate) => { if (data.categoryUpdate.errors.length > 0) { const backgroundImageError = data.categoryUpdate.errors.find( @@ -86,6 +98,27 @@ export const CategoryDetails: React.FC = ({ } }; + const [updateCategory, updateResult] = useCategoryUpdateMutation({ + onCompleted: handleCategoryUpdate + }); + + const handleBulkCategoryDelete = (data: CategoryBulkDelete) => { + if (data.categoryBulkDelete.errors.length === 0) { + closeModal(); + notify({ + text: intl.formatMessage(commonMessages.savedChanges) + }); + reset(); + } + }; + + const [ + categoryBulkDelete, + categoryBulkDeleteOpts + ] = useCategoryBulkDeleteMutation({ + onCompleted: handleBulkCategoryDelete + }); + const changeTab = (tabName: CategoryPageTab) => { reset(); navigate( @@ -114,329 +147,232 @@ export const CategoryDetails: React.FC = ({ }) ); + const formTransitionState = getMutationState( + updateResult.called, + updateResult.loading, + maybe(() => updateResult.data.categoryUpdate.errors) + ); + const removeDialogTransitionState = getMutationState( + deleteResult.called, + deleteResult.loading, + maybe(() => deleteResult.data.categoryDelete.errors) + ); + + const handleBulkProductDelete = (data: productBulkDelete) => { + if (data.productBulkDelete.errors.length === 0) { + closeModal(); + notify({ + text: intl.formatMessage(commonMessages.savedChanges) + }); + refetch(); + reset(); + } + }; + + const { loadNextPage, loadPreviousPage, pageInfo } = paginate( + params.activeTab === CategoryPageTab.categories + ? maybe(() => data.category.children.pageInfo) + : maybe(() => data.category.products.pageInfo), + paginationState, + params + ); + return ( - - {(deleteCategory, deleteResult) => ( - - {(updateCategory, updateResult) => { - const paginationState = createPaginationState(PAGINATE_BY, params); - const formTransitionState = getMutationState( - updateResult.called, - updateResult.loading, - maybe(() => updateResult.data.categoryUpdate.errors) - ); - const removeDialogTransitionState = getMutationState( - deleteResult.called, - deleteResult.loading, - maybe(() => deleteResult.data.categoryDelete.errors) - ); - return ( - + data.category.name)} /> + + {(productBulkDelete, productBulkDeleteOpts) => { + const categoryBulkDeleteMutationState = getMutationState( + categoryBulkDeleteOpts.called, + categoryBulkDeleteOpts.loading, + maybe(() => categoryBulkDeleteOpts.data.categoryBulkDelete.errors) + ); + const productBulkDeleteMutationState = getMutationState( + productBulkDeleteOpts.called, + productBulkDeleteOpts.loading, + maybe(() => productBulkDeleteOpts.data.productBulkDelete.errors) + ); + + return ( + <> + data.category)} + disabled={loading} + errors={maybe(() => updateResult.data.categoryUpdate.errors)} + onAddCategory={() => navigate(categoryAddUrl(id))} + onAddProduct={() => navigate(productAddUrl)} + onBack={() => + navigate( + maybe( + () => categoryUrl(data.category.parent.id), + categoryListUrl() + ) + ) + } + onCategoryClick={id => () => navigate(categoryUrl(id))} + onDelete={() => openModal("delete")} + onImageDelete={() => + updateCategory({ + variables: { + id, + input: { + backgroundImage: null + } + } + }) + } + onImageUpload={file => + updateCategory({ + variables: { + id, + input: { + backgroundImage: file + } + } + }) + } + onNextPage={loadNextPage} + onPreviousPage={loadPreviousPage} + pageInfo={pageInfo} + onProductClick={id => () => navigate(productUrl(id))} + onSubmit={formData => + updateCategory({ + variables: { + id, + input: { + backgroundImageAlt: formData.backgroundImageAlt, + descriptionJson: JSON.stringify(formData.description), + name: formData.name, + seo: { + description: formData.seoDescription, + title: formData.seoTitle + } + } + } + }) + } + products={maybe(() => + data.category.products.edges.map(edge => edge.node) + )} + saveButtonBarState={formTransitionState} + subcategories={maybe(() => + data.category.children.edges.map(edge => edge.node) + )} + subcategoryListToolbar={ + openModal("delete-categories", listElements)} + > + + + } + productListToolbar={ + openModal("delete-products", listElements)} + > + + + } + isChecked={isSelected} + selected={listElements.length} + toggle={toggle} + toggleAll={toggleAll} + /> + deleteCategory({ variables: { id } })} + open={params.action === "delete"} + title={intl.formatMessage({ + defaultMessage: "Delete category", + description: "dialog title" + })} + variant="delete" > - {({ data, loading, refetch }) => { - const handleBulkCategoryDelete = ( - data: CategoryBulkDelete - ) => { - if (data.categoryBulkDelete.errors.length === 0) { - closeModal(); - notify({ - text: intl.formatMessage(commonMessages.savedChanges) - }); - refetch(); - reset(); - } - }; - - const handleBulkProductDelete = (data: productBulkDelete) => { - if (data.productBulkDelete.errors.length === 0) { - closeModal(); - notify({ - text: intl.formatMessage(commonMessages.savedChanges) - }); - refetch(); - reset(); - } - }; - - const { loadNextPage, loadPreviousPage, pageInfo } = paginate( - params.activeTab === CategoryPageTab.categories - ? maybe(() => data.category.children.pageInfo) - : maybe(() => data.category.products.pageInfo), - paginationState, - params - ); - - return ( - <> - data.category.name)} /> - - {(categoryBulkDelete, categoryBulkDeleteOpts) => ( - - {(productBulkDelete, productBulkDeleteOpts) => { - const categoryBulkDeleteMutationState = getMutationState( - categoryBulkDeleteOpts.called, - categoryBulkDeleteOpts.loading, - maybe( - () => - categoryBulkDeleteOpts.data - .categoryBulkDelete.errors - ) - ); - const productBulkDeleteMutationState = getMutationState( - productBulkDeleteOpts.called, - productBulkDeleteOpts.loading, - maybe( - () => - productBulkDeleteOpts.data.productBulkDelete - .errors - ) - ); - - return ( - <> - data.category)} - disabled={loading} - errors={maybe( - () => - updateResult.data.categoryUpdate.errors - )} - onAddCategory={() => - navigate(categoryAddUrl(id)) - } - onAddProduct={() => navigate(productAddUrl)} - onBack={() => - navigate( - maybe( - () => - categoryUrl( - data.category.parent.id - ), - categoryListUrl() - ) - ) - } - onCategoryClick={id => () => - navigate(categoryUrl(id))} - onDelete={() => openModal("delete")} - onImageDelete={() => - updateCategory({ - variables: { - id, - input: { - backgroundImage: null - } - } - }) - } - onImageUpload={file => - updateCategory({ - variables: { - id, - input: { - backgroundImage: file - } - } - }) - } - onNextPage={loadNextPage} - onPreviousPage={loadPreviousPage} - pageInfo={pageInfo} - onProductClick={id => () => - navigate(productUrl(id))} - onSubmit={formData => - updateCategory({ - variables: { - id, - input: { - backgroundImageAlt: - formData.backgroundImageAlt, - descriptionJson: JSON.stringify( - formData.description - ), - name: formData.name, - seo: { - description: - formData.seoDescription, - title: formData.seoTitle - } - } - } - }) - } - products={maybe(() => - data.category.products.edges.map( - edge => edge.node - ) - )} - saveButtonBarState={formTransitionState} - subcategories={maybe(() => - data.category.children.edges.map( - edge => edge.node - ) - )} - subcategoryListToolbar={ - - openModal( - "delete-categories", - listElements - ) - } - > - - - } - productListToolbar={ - - openModal( - "delete-products", - listElements - ) - } - > - - - } - isChecked={isSelected} - selected={listElements.length} - toggle={toggle} - toggleAll={toggleAll} - /> - - deleteCategory({ variables: { id } }) - } - open={params.action === "delete"} - title={intl.formatMessage({ - defaultMessage: "Delete category", - description: "dialog title" - })} - variant="delete" - > - - - {maybe( - () => data.category.name, - "..." - )} - - ) - }} - /> - - - - - - params.ids.length > 0) - } - confirmButtonState={ - categoryBulkDeleteMutationState - } - onClose={closeModal} - onConfirm={() => - categoryBulkDelete({ - variables: { ids: params.ids } - }) - } - title={intl.formatMessage({ - defaultMessage: "Delete categories", - description: "dialog title" - })} - variant="delete" - > - - params.ids.length - ), - displayQuantity: ( - - {maybe(() => params.ids.length)} - - ) - }} - /> - - - - - - - productBulkDelete({ - variables: { ids: params.ids } - }) - } - title={intl.formatMessage({ - defaultMessage: "Delete products", - description: "dialog title" - })} - variant="delete" - > - {" "} - - params.ids.length - ), - displayQuantity: ( - - {maybe(() => params.ids.length)} - - ) - }} - /> - - - - ); - }} - - )} - - - ); - }} - - ); - }} - - )} - + + + {maybe(() => data.category.name, "...")} + + ) + }} + /> + + + + + + params.ids.length > 0) + } + confirmButtonState={categoryBulkDeleteMutationState} + onClose={closeModal} + onConfirm={() => + categoryBulkDelete({ + variables: { ids: params.ids } + }).then(() => refetch()) + } + title={intl.formatMessage({ + defaultMessage: "Delete categories", + description: "dialog title" + })} + variant="delete" + > + + params.ids.length), + displayQuantity: ( + {maybe(() => params.ids.length)} + ) + }} + /> + + + + + + + productBulkDelete({ + variables: { ids: params.ids } + }).then(() => refetch()) + } + title={intl.formatMessage({ + defaultMessage: "Delete products", + description: "dialog title" + })} + variant="delete" + > + + params.ids.length), + displayQuantity: ( + {maybe(() => params.ids.length)} + ) + }} + /> + + + + ); + }} + + ); }; export default CategoryDetails; diff --git a/src/categories/views/CategoryList/CategoryList.tsx b/src/categories/views/CategoryList/CategoryList.tsx index d3ec14a44..ac38342a7 100644 --- a/src/categories/views/CategoryList/CategoryList.tsx +++ b/src/categories/views/CategoryList/CategoryList.tsx @@ -18,8 +18,8 @@ import usePaginator, { import { getMutationState, maybe } from "@saleor/misc"; import { ListViews } from "@saleor/types"; import { CategoryListPage } from "../../components/CategoryListPage/CategoryListPage"; -import { TypedCategoryBulkDeleteMutation } from "../../mutations"; -import { TypedRootCategoriesQuery } from "../../queries"; +import { useCategoryBulkDeleteMutation } from "../../mutations"; +import { useRootCategoriesQuery } from "../../queries"; import { CategoryBulkDelete } from "../../types/CategoryBulkDelete"; import { categoryAddUrl, @@ -53,6 +53,20 @@ export const CategoryList: React.FC = ({ params }) => { ); const intl = useIntl(); + const paginationState = createPaginationState(settings.rowNumber, params); + const queryVariables = React.useMemo( + () => ({ + ...paginationState, + filter: getFilterVariables(params) + }), + [params] + ); + const { data, loading, refetch } = useRootCategoriesQuery({ + displayLoader: true, + require: ["categories"], + variables: queryVariables + }); + const tabs = getFilterTabs(); const currentTab = @@ -113,148 +127,129 @@ export const CategoryList: React.FC = ({ params }) => { handleTabChange(tabs.length + 1); }; - const paginationState = createPaginationState(settings.rowNumber, params); - const queryVariables = React.useMemo( - () => ({ - ...paginationState, - filter: getFilterVariables(params) - }), - [params] + const { loadNextPage, loadPreviousPage, pageInfo } = paginate( + maybe(() => data.categories.pageInfo), + paginationState, + params + ); + + const handleCategoryBulkDelete = (data: CategoryBulkDelete) => { + if (data.categoryBulkDelete.errors.length === 0) { + navigate(categoryListUrl(), true); + refetch(); + reset(); + } + }; + + const [ + categoryBulkDelete, + categoryBulkDeleteOpts + ] = useCategoryBulkDeleteMutation({ + onCompleted: handleCategoryBulkDelete + }); + + const bulkDeleteState = getMutationState( + categoryBulkDeleteOpts.called, + categoryBulkDeleteOpts.loading, + maybe(() => categoryBulkDeleteOpts.data.categoryBulkDelete.errors) ); return ( - - {({ data, loading, refetch }) => { - const { loadNextPage, loadPreviousPage, pageInfo } = paginate( - maybe(() => data.categories.pageInfo), - paginationState, - params - ); - - const handleCategoryBulkDelete = (data: CategoryBulkDelete) => { - if (data.categoryBulkDelete.errors.length === 0) { - navigate(categoryListUrl(), true); - refetch(); - reset(); - } - }; - return ( - + data.categories.edges.map(edge => edge.node), + [] + )} + currentTab={currentTab} + initialSearch={params.query || ""} + onSearchChange={query => changeFilterField({ query })} + onAll={() => navigate(categoryListUrl())} + onTabChange={handleTabChange} + onTabDelete={() => openModal("delete-search")} + onTabSave={() => openModal("save-search")} + tabs={tabs.map(tab => tab.name)} + settings={settings} + onAdd={() => navigate(categoryAddUrl())} + onRowClick={id => () => navigate(categoryUrl(id))} + disabled={loading} + onNextPage={loadNextPage} + onPreviousPage={loadPreviousPage} + onUpdateListSettings={updateListSettings} + pageInfo={pageInfo} + isChecked={isSelected} + selected={listElements.length} + toggle={toggle} + toggleAll={toggleAll} + toolbar={ + + navigate( + categoryListUrl({ + ...params, + action: "delete", + ids: listElements + }) + ) + } > - {(categoryBulkDelete, categoryBulkDeleteOpts) => { - const bulkDeleteState = getMutationState( - categoryBulkDeleteOpts.called, - categoryBulkDeleteOpts.loading, - maybe( - () => categoryBulkDeleteOpts.data.categoryBulkDelete.errors - ) - ); - - return ( - <> - data.categories.edges.map(edge => edge.node), - [] - )} - currentTab={currentTab} - initialSearch={params.query || ""} - onSearchChange={query => changeFilterField({ query })} - onAll={() => navigate(categoryListUrl())} - onTabChange={handleTabChange} - onTabDelete={() => openModal("delete-search")} - onTabSave={() => openModal("save-search")} - tabs={tabs.map(tab => tab.name)} - settings={settings} - onAdd={() => navigate(categoryAddUrl())} - onRowClick={id => () => navigate(categoryUrl(id))} - disabled={loading} - onNextPage={loadNextPage} - onPreviousPage={loadPreviousPage} - onUpdateListSettings={updateListSettings} - pageInfo={pageInfo} - isChecked={isSelected} - selected={listElements.length} - toggle={toggle} - toggleAll={toggleAll} - toolbar={ - - navigate( - categoryListUrl({ - ...params, - action: "delete", - ids: listElements - }) - ) - } - > - - - } - /> - - navigate( - categoryListUrl({ - ...params, - action: undefined, - ids: undefined - }) - ) - } - onConfirm={() => - categoryBulkDelete({ - variables: { - ids: params.ids - } - }) - } - open={params.action === "delete"} - title={intl.formatMessage({ - defaultMessage: "Delete categories", - description: "dialog title" - })} - variant="delete" - > - - params.ids.length), - displayQuantity: ( - {maybe(() => params.ids.length)} - ) - }} - /> - - - - - - - tabs[currentTab - 1].name, "...")} - /> - - ); + + + } + /> + + navigate( + categoryListUrl({ + ...params, + action: undefined, + ids: undefined + }) + ) + } + onConfirm={() => + categoryBulkDelete({ + variables: { + ids: params.ids + } + }) + } + open={params.action === "delete"} + title={intl.formatMessage({ + defaultMessage: "Delete categories", + description: "dialog title" + })} + variant="delete" + > + + params.ids.length), + displayQuantity: {maybe(() => params.ids.length)} }} - - ); - }} - + /> + + + + + + + tabs[currentTab - 1].name, "...")} + /> + ); }; export default CategoryList; diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index bacdc3954..0362dc578 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -13,13 +13,13 @@ import classNames from "classnames"; import React from "react"; import SVG from "react-inlinesvg"; import { FormattedMessage, useIntl } from "react-intl"; -import { RouteComponentProps, withRouter } from "react-router"; +import useRouter from "use-react-router"; import saleorDarkLogoSmall from "@assets/images/logo-dark-small.svg"; import saleorDarkLogo from "@assets/images/logo-dark.svg"; import menuArrowIcon from "@assets/images/menu-arrow-icon.svg"; -import AppProgressProvider from "@saleor/components/AppProgress"; import { createConfigurationMenu } from "@saleor/configuration"; +import useAppState from "@saleor/hooks/useAppState"; import useLocalStorage from "@saleor/hooks/useLocalStorage"; import useNavigator from "@saleor/hooks/useNavigator"; import useTheme from "@saleor/hooks/useTheme"; @@ -28,6 +28,8 @@ import ArrowDropdown from "@saleor/icons/ArrowDropdown"; import { maybe } from "@saleor/misc"; import { staffMemberDetailsUrl } from "@saleor/staff/urls"; import Container from "../Container"; +import ErrorPage from "../ErrorPage"; +import NotFoundPage from "../NotFoundPage"; import AppActionContext from "./AppActionContext"; import AppHeaderContext from "./AppHeaderContext"; import { appLoaderHeight, drawerWidth, drawerWidthExpanded } from "./consts"; @@ -273,222 +275,230 @@ interface AppLayoutProps { children: React.ReactNode; } -const AppLayout = withRouter, any>( - ({ children, location }: AppLayoutProps & RouteComponentProps) => { - const classes = useStyles({}); - const { isDark, toggleTheme } = useTheme(); - const [isMenuSmall, setMenuSmall] = useLocalStorage("isMenuSmall", false); - const [isDrawerOpened, setDrawerState] = React.useState(false); - const [isMenuOpened, setMenuState] = React.useState(false); - const appActionAnchor = React.useRef(); - const appHeaderAnchor = React.useRef(); - const anchor = React.useRef(); - const { logout, user } = useUser(); - const navigate = useNavigator(); - const intl = useIntl(); +const AppLayout: React.FC = ({ children }) => { + const classes = useStyles({}); + const { isDark, toggleTheme } = useTheme(); + const [isMenuSmall, setMenuSmall] = useLocalStorage("isMenuSmall", false); + const [isDrawerOpened, setDrawerState] = React.useState(false); + const [isMenuOpened, setMenuState] = React.useState(false); + const appActionAnchor = React.useRef(); + const appHeaderAnchor = React.useRef(); + const anchor = React.useRef(); + const { logout, user } = useUser(); + const navigate = useNavigator(); + const intl = useIntl(); + const [appState, dispatchAppState] = useAppState(); + const { location } = useRouter(); - const menuStructure = createMenuStructure(intl); - const configurationMenu = createConfigurationMenu(intl); - const userPermissions = maybe(() => user.permissions, []); + const menuStructure = createMenuStructure(intl); + const configurationMenu = createConfigurationMenu(intl); + const userPermissions = maybe(() => user.permissions, []); - const renderConfigure = configurationMenu.some(section => - section.menuItems.some( - menuItem => - !!userPermissions.find( - userPermission => userPermission.code === menuItem.permission - ) - ) - ); + const renderConfigure = configurationMenu.some(section => + section.menuItems.some( + menuItem => + !!userPermissions.find( + userPermission => userPermission.code === menuItem.permission + ) + ) + ); - const handleLogout = () => { - setMenuState(false); - logout(); - }; + const handleLogout = () => { + setMenuState(false); + logout(); + }; - const handleViewerProfile = () => { - setMenuState(false); - navigate(staffMemberDetailsUrl(user.id)); - }; + const handleViewerProfile = () => { + setMenuState(false); + navigate(staffMemberDetailsUrl(user.id)); + }; - const handleMenuItemClick = (url: string, event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - setDrawerState(false); - navigate(url); - }; + const handleMenuItemClick = (url: string, event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + setDrawerState(false); + navigate(url); + }; - const handleIsMenuSmall = () => { - setMenuSmall(!isMenuSmall); - }; + const handleIsMenuSmall = () => { + setMenuSmall(!isMenuSmall); + }; - return ( - - {({ isProgress }) => ( - - -
-
- setDrawerState(false)} - open={isDrawerOpened} - small={!isMenuSmall} - > -
- -
- -
- -
-
- -
-
-
- {isProgress ? ( - - ) : ( -
- )} -
-
- -
-
setDrawerState(!isDrawerOpened)} - > - - - - -
-
-
-
- -
- - ) - } - className={classes.userChip} - label={ - <> - {user.email} - - - } - onClick={() => setMenuState(!isMenuOpened)} - /> - - {({ TransitionProps, placement }) => ( - - - setMenuState(false)} - mouseEvent="onClick" - > - - - - - - - - - - - - )} - -
-
-
- -
-
{children}
-
-
-
+ const handleErrorBack = () => { + navigate("/"); + dispatchAppState({ + payload: { + error: null + }, + type: "displayError" + }); + }; + + return ( + + +
+
+ setDrawerState(false)} + open={isDrawerOpened} + small={!isMenuSmall} + > +
+
- - - )} - - ); - } -); + +
+ +
+
+ +
+
+
+ {appState.loading ? ( + + ) : ( +
+ )} +
+
+ +
+
setDrawerState(!isDrawerOpened)} + > + + + + +
+
+
+
+ +
+ + ) + } + className={classes.userChip} + label={ + <> + {user.email} + + + } + onClick={() => setMenuState(!isMenuOpened)} + /> + + {({ TransitionProps, placement }) => ( + + + setMenuState(false)} + mouseEvent="onClick" + > + + + + + + + + + + + + )} + +
+
+
+ +
+
+ {appState.error ? ( + appState.error === "not-found" ? ( + + ) : ( + + ) + ) : ( + children + )} +
+
+
+
+
+ + + ); +}; export default AppLayout; diff --git a/src/components/AppProgress/index.tsx b/src/components/AppProgress/index.tsx deleted file mode 100644 index 4ff8f199b..000000000 --- a/src/components/AppProgress/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; - -interface IAppProgressContext { - isProgress: boolean; - setProgressState: (isOpened: boolean) => void; -} - -export const AppProgressContext = React.createContext( - undefined -); - -export const AppProgressProvider: React.FC<{}> = ({ children }) => { - const [isProgress, setProgressState] = React.useState(false); - - return ( - - {children} - - ); -}; - -export const AppProgress = AppProgressContext.Consumer; -export default AppProgress; diff --git a/src/components/ErrorPage/ErrorPage.tsx b/src/components/ErrorPage/ErrorPage.tsx index 77b6ba041..51810fb5f 100644 --- a/src/components/ErrorPage/ErrorPage.tsx +++ b/src/components/ErrorPage/ErrorPage.tsx @@ -48,7 +48,7 @@ const useStyles = makeStyles(theme => ({ root: { alignItems: "center", display: "flex", - height: "calc(100vh - 88px)" + height: "calc(100vh - 180px)" }, upperHeader: { fontWeight: 600 as 600 diff --git a/src/components/NotFoundPage/NotFoundPage.tsx b/src/components/NotFoundPage/NotFoundPage.tsx index 3dc5d923f..a71160355 100644 --- a/src/components/NotFoundPage/NotFoundPage.tsx +++ b/src/components/NotFoundPage/NotFoundPage.tsx @@ -43,8 +43,7 @@ const useStyles = makeStyles(theme => ({ root: { alignItems: "center", display: "flex", - height: "100vh", - width: "100vw" + height: "calc(100vh - 180px)" } })); diff --git a/src/containers/AppState/AppState.tsx b/src/containers/AppState/AppState.tsx new file mode 100644 index 000000000..9f3b5a2cc --- /dev/null +++ b/src/containers/AppState/AppState.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import useRouter from "use-react-router"; + +import appStateReducer, { AppStateReducerAction } from "./reducer"; +import IAppState, { initialAppState } from "./state"; + +export type AppStateContextType = [ + IAppState, + React.Dispatch +]; +export const AppStateContext = React.createContext([ + initialAppState, + () => undefined +]); +const AppStateProvider: React.FC = ({ children }) => { + const { location } = useRouter(); + const stateAndDispatch = React.useReducer(appStateReducer, initialAppState); + const [state, dispatch] = stateAndDispatch; + + React.useEffect(() => { + if (!!state.error) { + dispatch({ + payload: { + error: null + }, + type: "displayError" + }); + } + }, [location]); + + return ( + + {children} + + ); +}; + +export const { Consumer } = AppStateContext; + +export default AppStateProvider; diff --git a/src/containers/AppState/index.ts b/src/containers/AppState/index.ts new file mode 100644 index 000000000..04b1b0a50 --- /dev/null +++ b/src/containers/AppState/index.ts @@ -0,0 +1,2 @@ +export { default } from "./AppState"; +export * from "./AppState"; diff --git a/src/containers/AppState/reducer.ts b/src/containers/AppState/reducer.ts new file mode 100644 index 000000000..1ed4654d4 --- /dev/null +++ b/src/containers/AppState/reducer.ts @@ -0,0 +1,42 @@ +import IAppState, { AppError } from "./state"; + +export type AppStateReducerActionType = "displayError" | "displayLoader"; + +export interface AppStateReducerAction { + payload: Partial<{ + error: AppError; + value: boolean; + }>; + type: AppStateReducerActionType; +} + +function displayError(prevState: IAppState, error: AppError): IAppState { + return { + ...prevState, + error, + loading: false + }; +} + +function displayLoader(prevState: IAppState, value: boolean): IAppState { + return { + ...prevState, + loading: value + }; +} + +function reduceAppState( + prevState: IAppState, + action: AppStateReducerAction +): IAppState { + switch (action.type) { + case "displayError": + return displayError(prevState, action.payload.error); + case "displayLoader": + return displayLoader(prevState, action.payload.value); + default: + return prevState; + } +} + +export default reduceAppState; diff --git a/src/containers/AppState/state.ts b/src/containers/AppState/state.ts new file mode 100644 index 000000000..efea07b71 --- /dev/null +++ b/src/containers/AppState/state.ts @@ -0,0 +1,13 @@ +export type AppError = "unhandled" | "not-found"; + +interface IAppState { + error: AppError | null; + loading: boolean; +} + +export const initialAppState: IAppState = { + error: null, + loading: false +}; + +export default IAppState; diff --git a/src/hooks/makeMutation.ts b/src/hooks/makeMutation.ts new file mode 100644 index 000000000..b6cfd4a91 --- /dev/null +++ b/src/hooks/makeMutation.ts @@ -0,0 +1,65 @@ +import { ApolloError } from "apollo-client"; +import { DocumentNode } from "graphql"; +import { + MutationFunction, + MutationResult, + useMutation as useBaseMutation +} from "react-apollo"; +import { useIntl } from "react-intl"; + +import { commonMessages } from "@saleor/intl"; +import { maybe } from "@saleor/misc"; +import useNotifier from "./useNotifier"; + +type UseMutation = [ + MutationFunction, + MutationResult +]; +type UseMutationCbs = Partial<{ + onCompleted: (data: TData) => void; + onError: (error: ApolloError) => void; +}>; +type UseMutationHook = ( + cbs: UseMutationCbs +) => UseMutation; + +function makeMutation( + mutation: DocumentNode +): UseMutationHook { + function useMutation({ + onCompleted, + onError + }: UseMutationCbs): UseMutation { + const notify = useNotifier(); + const intl = useIntl(); + const [mutateFn, result] = useBaseMutation(mutation, { + onCompleted, + onError: (err: ApolloError) => { + if ( + maybe( + () => + err.graphQLErrors[0].extensions.exception.code === + "ReadOnlyException" + ) + ) { + notify({ + text: intl.formatMessage(commonMessages.readOnly) + }); + } else { + notify({ + text: intl.formatMessage(commonMessages.somethingWentWrong) + }); + } + if (onError) { + onError(err); + } + } + }); + + return [mutateFn, result]; + } + + return useMutation; +} + +export default makeMutation; diff --git a/src/hooks/makeQuery.ts b/src/hooks/makeQuery.ts new file mode 100644 index 000000000..a16909766 --- /dev/null +++ b/src/hooks/makeQuery.ts @@ -0,0 +1,115 @@ +import { ApolloQueryResult } from "apollo-client"; +import { DocumentNode } from "graphql"; +import { useEffect } from "react"; +import { QueryResult, useQuery as useBaseQuery } from "react-apollo"; +import { useIntl } from "react-intl"; + +import { commonMessages } from "@saleor/intl"; +import { maybe, RequireAtLeastOne } from "@saleor/misc"; +import useAppState from "./useAppState"; +import useNotifier from "./useNotifier"; + +export interface LoadMore { + loadMore: ( + mergeFunc: (prev: TData, next: TData) => TData, + extraVariables: Partial + ) => Promise>; +} + +type UseQuery = QueryResult & + LoadMore; +type UseQueryOpts = Partial<{ + displayLoader: boolean; + require: Array; + skip: boolean; + variables: TVariables; +}>; +type UseQueryHook = ( + opts: UseQueryOpts +) => UseQuery; + +function makeQuery( + query: DocumentNode +): UseQueryHook { + function useQuery({ + displayLoader, + require, + skip, + variables + }: UseQueryOpts): UseQuery { + const notify = useNotifier(); + const intl = useIntl(); + const [, dispatchAppState] = useAppState(); + const queryData = useBaseQuery(query, { + context: { + useBatching: true + }, + errorPolicy: "all", + fetchPolicy: "cache-and-network", + skip, + variables + }); + + useEffect(() => { + if (displayLoader) { + dispatchAppState({ + payload: { + value: queryData.loading + }, + type: "displayLoader" + }); + } + }, [queryData.loading]); + + if (queryData.error) { + if ( + !queryData.error.graphQLErrors.every( + err => + maybe(() => err.extensions.exception.code) === "PermissionDenied" + ) + ) { + notify({ + text: intl.formatMessage(commonMessages.somethingWentWrong) + }); + } + } + + const loadMore = ( + mergeFunc: (previousResults: TData, fetchMoreResult: TData) => TData, + extraVariables: RequireAtLeastOne + ) => + queryData.fetchMore({ + query, + updateQuery: (previousResults, { fetchMoreResult }) => { + if (!fetchMoreResult) { + return previousResults; + } + return mergeFunc(previousResults, fetchMoreResult); + }, + variables: { ...variables, ...extraVariables } + }); + + if ( + !queryData.loading && + require && + queryData.data && + !require.reduce((acc, key) => acc && queryData.data[key] !== null, true) + ) { + dispatchAppState({ + payload: { + error: "not-found" + }, + type: "displayError" + }); + } + + return { + ...queryData, + loadMore + }; + } + + return useQuery; +} + +export default makeQuery; diff --git a/src/hooks/useAppState.ts b/src/hooks/useAppState.ts new file mode 100644 index 000000000..83abfa181 --- /dev/null +++ b/src/hooks/useAppState.ts @@ -0,0 +1,11 @@ +import React from "react"; + +import { AppStateContext } from "../containers/AppState"; + +function useAppState() { + const stateAndDispatch = React.useContext(AppStateContext); + + return stateAndDispatch; +} + +export default useAppState; diff --git a/src/index.tsx b/src/index.tsx index 7c193a445..b229ec5d1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,9 +8,11 @@ import { createUploadLink } from "apollo-upload-client"; import React from "react"; import { ApolloProvider } from "react-apollo"; import { render } from "react-dom"; +import ErrorBoundary from "react-error-boundary"; import { useIntl } from "react-intl"; import { BrowserRouter, Route, Switch } from "react-router-dom"; +import useAppState from "@saleor/hooks/useAppState"; import AttributeSection from "./attributes"; import { attributeSection } from "./attributes/urls"; import Auth, { getAuthToken, removeAuthToken } from "./auth"; @@ -20,7 +22,7 @@ import SectionRoute from "./auth/components/SectionRoute"; import { hasPermission } from "./auth/misc"; import CategorySection from "./categories"; import CollectionSection from "./collections"; -import { AppProgressProvider } from "./components/AppProgress"; +import AppLayout from "./components/AppLayout"; import { DateProvider } from "./components/Date"; import { LocaleProvider } from "./components/Locale"; import { MessageManager } from "./components/messages"; @@ -29,6 +31,7 @@ import ThemeProvider from "./components/Theme"; import { WindowTitle } from "./components/WindowTitle"; import { API_URI, APP_MOUNT_URI } from "./config"; import ConfigurationSection, { createConfigurationMenu } from "./configuration"; +import AppStateProvider from "./containers/AppState"; import { CustomerSection } from "./customers"; import DiscountSection from "./discounts"; import HomePage from "./home"; @@ -119,11 +122,11 @@ const App: React.FC = () => { - + - + @@ -135,6 +138,7 @@ const App: React.FC = () => { const Routes: React.FC = () => { const intl = useIntl(); + const [, dispatchAppState] = useAppState(); return ( <> @@ -148,109 +152,124 @@ const Routes: React.FC = () => { user }) => isAuthenticated && !tokenAuthLoading && !tokenVerifyLoading ? ( - - - - - - - - - - - - - - - - - - - - - {createConfigurationMenu(intl).filter(menu => - menu.menuItems.map(item => hasPermission(item.permission, user)) - ).length > 0 && ( - - )} - - + + + dispatchAppState({ + payload: { + error: "unhandled" + }, + type: "displayError" + }) + } + > + + + + + + + + + + + + + + + + + + + + + {createConfigurationMenu(intl).filter(menu => + menu.menuItems.map(item => + hasPermission(item.permission, user) + ) + ).length > 0 && ( + + )} + + + + ) : hasToken && tokenVerifyLoading ? ( ) : ( diff --git a/src/products/utils/data.ts b/src/products/utils/data.ts index 735e165bd..4c3a2eec6 100644 --- a/src/products/utils/data.ts +++ b/src/products/utils/data.ts @@ -174,19 +174,23 @@ export function getProductUpdatePageFormData( publicationDate: maybe(() => product.publicationDate, ""), seoDescription: maybe(() => product.seoDescription, ""), seoTitle: maybe(() => product.seoTitle, ""), - sku: maybe(() => - product.productType.hasVariants - ? undefined - : variants && variants[0] - ? variants[0].sku - : undefined + sku: maybe( + () => + product.productType.hasVariants + ? undefined + : variants && variants[0] + ? variants[0].sku + : undefined, + "" ), - stockQuantity: maybe(() => - product.productType.hasVariants - ? undefined - : variants && variants[0] - ? variants[0].quantity - : undefined + stockQuantity: maybe( + () => + product.productType.hasVariants + ? undefined + : variants && variants[0] + ? variants[0].quantity + : undefined, + 0 ) }; } diff --git a/src/queries.tsx b/src/queries.tsx index 4b259b587..1428f5db4 100644 --- a/src/queries.tsx +++ b/src/queries.tsx @@ -5,9 +5,7 @@ import React from "react"; import { Query, QueryResult } from "react-apollo"; import { useIntl } from "react-intl"; -import AppProgress from "./components/AppProgress"; -import ErrorPage from "./components/ErrorPage/ErrorPage"; -import useNavigator from "./hooks/useNavigator"; +import useAppState from "./hooks/useAppState"; import useNotifier from "./hooks/useNotifier"; import { commonMessages } from "./intl"; import { maybe, RequireAtLeastOne } from "./misc"; @@ -68,87 +66,108 @@ export function TypedQuery( query: DocumentNode ): React.FC> { return ({ children, displayLoader, skip, variables, require }) => { - const navigate = useNavigator(); const pushMessage = useNotifier(); + const [, dispatchAppState] = useAppState(); const intl = useIntl(); return ( - - {({ setProgressState }) => ( - - {(queryData: QueryResult) => { - if (queryData.error) { - if ( - !queryData.error.graphQLErrors.every( - err => - maybe(() => err.extensions.exception.code) === - "PermissionDenied" - ) - ) { - pushMessage({ - text: intl.formatMessage(commonMessages.somethingWentWrong) - }); + + {(queryData: QueryResult) => { + if (queryData.error) { + if ( + !queryData.error.graphQLErrors.every( + err => + maybe(() => err.extensions.exception.code) === + "PermissionDenied" + ) + ) { + pushMessage({ + text: intl.formatMessage(commonMessages.somethingWentWrong) + }); + } + } + + const loadMore = ( + mergeFunc: ( + previousResults: TData, + fetchMoreResult: TData + ) => TData, + extraVariables: RequireAtLeastOne + ) => + queryData.fetchMore({ + query, + updateQuery: (previousResults, { fetchMoreResult }) => { + if (!fetchMoreResult) { + return previousResults; } - } + return mergeFunc(previousResults, fetchMoreResult); + }, + variables: { ...variables, ...extraVariables } + }); - const loadMore = ( - mergeFunc: ( - previousResults: TData, - fetchMoreResult: TData - ) => TData, - extraVariables: RequireAtLeastOne - ) => - queryData.fetchMore({ - query, - updateQuery: (previousResults, { fetchMoreResult }) => { - if (!fetchMoreResult) { - return previousResults; - } - return mergeFunc(previousResults, fetchMoreResult); - }, - variables: { ...variables, ...extraVariables } - }); + if ( + !queryData.loading && + require && + queryData.data && + !require.reduce( + (acc, key) => acc && queryData.data[key] !== null, + true + ) + ) { + dispatchAppState({ + payload: { + error: "not-found" + }, + type: "displayError" + }); + } - let childrenOrNotFound = children({ + if (displayLoader) { + return ( + + dispatchAppState({ + payload: { + value: false + }, + type: "displayLoader" + }) + } + onLoading={() => + dispatchAppState({ + payload: { + value: true + }, + type: "displayLoader" + }) + } + > + {children({ + ...queryData, + loadMore + })} + + ); + } + + return ( + <> + {children({ ...queryData, loadMore - }); - if ( - !queryData.loading && - require && - queryData.data && - !require.reduce( - (acc, key) => acc && queryData.data[key] !== null, - true - ) - ) { - childrenOrNotFound = navigate("/")} />; - } - - if (displayLoader) { - return ( - setProgressState(false)} - onLoading={() => setProgressState(true)} - > - {childrenOrNotFound} - - ); - } - - return <>{childrenOrNotFound}; - }} - - )} - + })} + + ); + }} + ); }; } diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 108699494..2ee2c975f 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -97522,6 +97522,7 @@ Ctrl + K" disabled="" name="sku" type="text" + value="" />