diff --git a/demo/admin/src/products/categories/AssignProductsGrid.tsx b/demo/admin/src/products/categories/AssignProductsGrid.tsx new file mode 100644 index 00000000000..840a71e033b --- /dev/null +++ b/demo/admin/src/products/categories/AssignProductsGrid.tsx @@ -0,0 +1,86 @@ +import { gql, useApolloClient, useQuery } from "@apollo/client"; +import { Savable } from "@comet/admin"; +import { CircularProgress } from "@mui/material"; +import { + GQLGetProductIdsForProductCategoryQuery, + GQLGetProductIdsForProductCategoryQueryVariables, + GQLSetProductCategoryMutation, + GQLSetProductCategoryMutationVariables, +} from "@src/products/categories/AssignProductsGrid.generated"; +import { ProductsSelectGrid } from "@src/products/categories/ProductsSelectGrid"; +import isEqual from "lodash.isequal"; +import React, { useEffect, useMemo, useState } from "react"; +import { FormattedMessage } from "react-intl"; + +const setProductCategoryMutation = gql` + mutation SetProductCategory($id: ID!, $input: [ID!]!) { + updateProductCategory(id: $id, input: { products: $input }) { + id + } + } +`; + +const getProductIdsForProductCategory = gql` + query GetProductIdsForProductCategory($id: ID!) { + products(filter: { category: { equal: $id } }) { + nodes { + id + } + } + } +`; + +interface FormProps { + productCategoryId: string; +} + +export function AssignProductsGrid({ productCategoryId }: FormProps): React.ReactElement { + const client = useApolloClient(); + + const { data, error, loading } = useQuery( + getProductIdsForProductCategory, + { + variables: { id: productCategoryId }, + }, + ); + + const initialValues = useMemo(() => { + return data?.products.nodes ? data.products.nodes.map((product) => product.id) : []; + }, [data]); + const [values, setValues] = useState(initialValues); + useEffect(() => { + setValues(initialValues); + }, [initialValues]); + + if (error) return ; + if (loading) return ; + + return ( + <> + { + await client.mutate({ + mutation: setProductCategoryMutation, + variables: { id: productCategoryId, input: values }, + update: (cache, result) => cache.evict({ fieldName: "products" }), + }); + return true; + }} + hasChanges={!isEqual(initialValues.sort(), values.sort())} + doReset={() => { + setValues(initialValues); + }} + /> + { + setValues(newSelectionModel.map((rowId) => String(rowId))); + }, + }} + /> + + ); +} diff --git a/demo/admin/src/products/categories/AssignedProductsGrid.tsx b/demo/admin/src/products/categories/AssignedProductsGrid.tsx new file mode 100644 index 00000000000..fecb991f905 --- /dev/null +++ b/demo/admin/src/products/categories/AssignedProductsGrid.tsx @@ -0,0 +1,57 @@ +import { CancelButton, SaveBoundary, SaveBoundarySaveButton } from "@comet/admin"; +import { Add as AddIcon } from "@comet/admin-icons"; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { AssignProductsGrid } from "@src/products/categories/AssignProductsGrid"; +import { ProductsGrid } from "@src/products/ProductsGrid"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +type Props = { + productCategoryId: string; +}; + +export function AssignedProductsGrid({ productCategoryId }: Props): React.ReactElement { + const [isOpen, setIsOpen] = React.useState(false); + + const handleCloseDialog = () => { + setIsOpen(false); + }; + return ( + <> + } onClick={() => setIsOpen(true)} variant="contained" color="primary"> + + + } + filter={{ category: { equal: productCategoryId } }} + /> + { + setIsOpen(false); + }} + > + + + + + + + + + {/* TODO Missing close-dialog-unsaved-changes-check */} + + + + + + + ); +} diff --git a/demo/admin/src/products/categories/ProductCategoriesPage.tsx b/demo/admin/src/products/categories/ProductCategoriesPage.tsx index 64f884b8c64..f17c6a31bc0 100644 --- a/demo/admin/src/products/categories/ProductCategoriesPage.tsx +++ b/demo/admin/src/products/categories/ProductCategoriesPage.tsx @@ -1,5 +1,5 @@ import { - MainContent, + MainContent, RouterTab, RouterTabs, SaveBoundary, SaveBoundarySaveButton, Stack, @@ -17,6 +17,8 @@ import { useIntl } from "react-intl"; import ProductCategoriesTable from "./ProductCategoriesTable"; import ProductCategoryForm from "./ProductCategoryForm"; +import { AssignedProductsGrid } from "@src/products/categories/AssignedProductsGrid"; +import { Box } from "@mui/material"; const FormToolbar = () => ( }> @@ -45,7 +47,27 @@ const ProductCategoriesPage: React.FC = () => { {(selectedId) => ( - + + + + + + + + + + )} diff --git a/demo/admin/src/products/categories/ProductsSelectGrid.tsx b/demo/admin/src/products/categories/ProductsSelectGrid.tsx new file mode 100644 index 00000000000..4057baced16 --- /dev/null +++ b/demo/admin/src/products/categories/ProductsSelectGrid.tsx @@ -0,0 +1,226 @@ +import { useQuery } from "@apollo/client"; +import { + DataGridToolbar, + GridCellContent, + GridColDef, + GridColumnsButton, + GridFilterButton, + muiGridFilterToGql, + muiGridSortToGql, + ToolbarFillSpace, + ToolbarItem, + useBufferedRowCount, + useDataGridRemote, + usePersistentColumnState, +} from "@comet/admin"; +import { useTheme } from "@mui/material"; +import { GridCallbackDetails } from "@mui/x-data-grid/models/api"; +import { GridInputSelectionModel, GridSelectionModel } from "@mui/x-data-grid/models/gridSelectionModel"; +import { DataGridPro, GridFilterInputSingleSelect, GridToolbarQuickFilter } from "@mui/x-data-grid-pro"; +import gql from "graphql-tag"; +import * as React from "react"; +import { FormattedNumber, useIntl } from "react-intl"; + +import { + GQLProductsSelectGridItemFragment, + GQLProductsSelectGridListQuery, + GQLProductsSelectGridListQueryVariables, + GQLProductsSelectGridRelationsQuery, + GQLProductsSelectGridRelationsQueryVariables, +} from "./ProductsSelectGrid.generated"; + +function ProductsGridToolbar() { + return ( + + + + + + + + + + + + + ); +} + +type Props = { + dataGridProps?: { + checkboxSelection: boolean; + keepNonExistentRowsSelected: boolean; + selectionModel?: GridInputSelectionModel; + onSelectionModelChange?: (selectionModel: GridSelectionModel, details: GridCallbackDetails) => void; + }; +}; + +export function ProductsSelectGrid({ dataGridProps: forwardedDataGridProps }: Props) { + const dataGridProps = { ...useDataGridRemote(), ...usePersistentColumnState("ProductsGrid"), ...forwardedDataGridProps }; + const sortModel = dataGridProps.sortModel; + const { data: relationsData } = useQuery( + productRelationsQuery, + ); + const intl = useIntl(); + const theme = useTheme(); + + const columns: GridColDef[] = [ + { + field: "overview", + headerName: "Overview", + minWidth: 200, + flex: 1, + sortBy: ["title", "price", "type", "category"], + visible: theme.breakpoints.down("md"), + renderCell: ({ row }) => { + const secondaryValues = [ + typeof row.price === "number" && intl.formatNumber(row.price, { style: "currency", currency: "EUR" }), + row.type, + row.category?.title, + row.inStock + ? intl.formatMessage({ id: "comet.products.product.inStock", defaultMessage: "In Stock" }) + : intl.formatMessage({ id: "comet.products.product.outOfStock", defaultMessage: "Out of Stock" }), + ]; + return ; + }, + }, + { + field: "title", + headerName: "Title", + minWidth: 150, + flex: 1, + visible: theme.breakpoints.up("md"), + }, + { field: "description", headerName: "Description", flex: 1, minWidth: 150 }, + { + field: "price", + headerName: "Price", + minWidth: 100, + flex: 1, + type: "number", + visible: theme.breakpoints.up("md"), + renderCell: ({ row }) => (typeof row.price === "number" ? : "-"), + }, + { + field: "type", + headerName: "Type", + width: 100, + type: "singleSelect", + visible: theme.breakpoints.up("md"), + valueOptions: ["Cap", "Shirt", "Tie"], + }, + { + field: "additionalTypes", + headerName: "Additional Types", + width: 150, + renderCell: (params) => <>{params.row.additionalTypes.join(", ")}, + filterOperators: [ + { + value: "contains", + getApplyFilterFn: (filterItem) => { + throw new Error("not implemented, we filter server side"); + }, + InputComponent: GridFilterInputSingleSelect, + }, + ], + valueOptions: ["Cap", "Shirt", "Tie"], + }, + { + field: "tags", + headerName: "Tags", + flex: 1, + minWidth: 150, + renderCell: (params) => <>{params.row.tags.map((tag) => tag.title).join(", ")}, + filterOperators: [ + { + value: "contains", + getApplyFilterFn: (filterItem) => { + throw new Error("not implemented, we filter server side"); + }, + InputComponent: GridFilterInputSingleSelect, + }, + ], + valueOptions: relationsData?.productTags.nodes.map((i) => ({ value: i.id, label: i.title })), + }, + ]; + + const { filter: gqlFilter, search: gqlSearch } = muiGridFilterToGql(columns, dataGridProps.filterModel); + + const { data, loading, error } = useQuery(productsQuery, { + variables: { + filter: gqlFilter, + search: gqlSearch, + offset: dataGridProps.page * dataGridProps.pageSize, + limit: dataGridProps.pageSize, + sort: muiGridSortToGql(sortModel, dataGridProps.apiRef), + }, + }); + const rows = data?.products.nodes ?? []; + const rowCount = useBufferedRowCount(data?.products.totalCount); + + return ( + + ); +} + +const productsFragment = gql` + fragment ProductsSelectGridItem on Product { + id + title + description + price + type + additionalTypes + inStock + status + category { + id + title + } + tags { + id + title + } + } +`; + +const productsQuery = gql` + query ProductsSelectGridList($offset: Int!, $limit: Int!, $sort: [ProductSort!], $filter: ProductFilter, $search: String) { + products(offset: $offset, limit: $limit, sort: $sort, filter: $filter, search: $search) { + nodes { + id + ...ProductsSelectGridItem + } + totalCount + } + } + ${productsFragment} +`; + +const productRelationsQuery = gql` + query ProductsSelectGridRelations { + productCategories { + nodes { + id + title + } + } + productTags { + nodes { + id + title + } + } + } +`;