diff --git a/admin/package.json b/admin/package.json
index dd40bb332..0e353da11 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -54,7 +54,8 @@
"react-final-form": "^6.5.9",
"react-intl": "^7.1.14",
"react-router": "^5.3.4",
- "react-router-dom": "^5.3.4"
+ "react-router-dom": "^5.3.4",
+ "use-debounce": "^10.0.6"
},
"devDependencies": {
"@comet/admin-generator": "8.18.0",
diff --git a/admin/src/common/MasterMenu.tsx b/admin/src/common/MasterMenu.tsx
index a266ccaf1..de5740c55 100644
--- a/admin/src/common/MasterMenu.tsx
+++ b/admin/src/common/MasterMenu.tsx
@@ -1,4 +1,4 @@
-import { Assets, Dashboard, PageTree, Snips, Tag, Wrench } from "@comet/admin-icons";
+import { Assets, Dashboard, Domain, PageTree, Snips, Tag, Wrench } from "@comet/admin-icons";
import {
ContentScopeIndicator,
createRedirectsPage,
@@ -14,6 +14,7 @@ import { DashboardPage } from "@src/dashboard/DashboardPage";
import { Link } from "@src/documents/links/Link";
import { Page } from "@src/documents/pages/Page";
import { EditFooterPage } from "@src/footers/EditFooterPage";
+import { ProductCategoriesPage } from "@src/products/ProductCategoriesPage";
import { ProductsPage } from "@src/products/ProductsPage";
import { FormattedMessage } from "react-intl";
@@ -61,6 +62,16 @@ export const masterMenuData: MasterMenuData = [
},
requiredPermission: "products",
},
+ {
+ type: "route",
+ primary: ,
+ icon: ,
+ route: {
+ path: "/product-categories",
+ component: ProductCategoriesPage,
+ },
+ requiredPermission: "productCategories",
+ },
{
type: "route",
primary: ,
diff --git a/admin/src/products/ProductCategoriesPage.tsx b/admin/src/products/ProductCategoriesPage.tsx
new file mode 100644
index 000000000..d9b288694
--- /dev/null
+++ b/admin/src/products/ProductCategoriesPage.tsx
@@ -0,0 +1,53 @@
+import {
+ SaveBoundary,
+ Stack,
+ StackMainContent,
+ StackPage,
+ StackSwitch,
+ StackToolbar,
+ ToolbarAutomaticTitleItem,
+ ToolbarBackButton,
+} from "@comet/admin";
+import { ContentScopeIndicator } from "@comet/cms-admin";
+import { type FunctionComponent } from "react";
+import { FormattedMessage } from "react-intl";
+
+import { ProductCategoriesDataGrid } from "./components/productCategoriesDataGrid/ProductCategoriesDataGrid";
+import { ProductCategoryForm } from "./components/productCategoryForm/ProductCategoryForm";
+import { ProductCategoryToolbar } from "./components/productCategoryToolbar/ProductCategoryToolbar";
+
+export const ProductCategoriesPage: FunctionComponent = () => {
+ return (
+ }>
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(id) => (
+
+
+
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.gql.ts b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.gql.ts
new file mode 100644
index 000000000..1b16afd6d
--- /dev/null
+++ b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.gql.ts
@@ -0,0 +1,55 @@
+import { gql } from "@apollo/client";
+
+const productCategoriesFragment = gql`
+ fragment ProductCategoriesGridItem on ProductCategory {
+ id
+ name
+ slug
+ position
+ parentCategory {
+ id
+ name
+ }
+ }
+`;
+
+export const productCategoriesQuery = gql`
+ query ProductCategoriesGrid(
+ $scope: ProductCategoryScopeInput!
+ $offset: Int!
+ $limit: Int!
+ $sort: [ProductCategorySort!]!
+ $search: String
+ $filter: ProductCategoryFilter
+ ) {
+ productCategories(scope: $scope, offset: $offset, limit: $limit, sort: $sort, search: $search, filter: $filter) {
+ nodes {
+ ...ProductCategoriesGridItem
+ }
+ totalCount
+ }
+ }
+ ${productCategoriesFragment}
+`;
+
+export const deleteProductCategoryMutation = gql`
+ mutation DeleteProductCategory($id: ID!) {
+ deleteProductCategory(id: $id)
+ }
+`;
+
+export const updateProductCategoryPositionMutation = gql`
+ mutation UpdateProductCategoryPosition($id: ID!, $input: ProductCategoryUpdateInput!) {
+ updateProductCategory(id: $id, input: $input) {
+ productCategory {
+ id
+ position
+ updatedAt
+ }
+ errors {
+ code
+ field
+ }
+ }
+ }
+`;
diff --git a/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx
new file mode 100644
index 000000000..4bb647ba6
--- /dev/null
+++ b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx
@@ -0,0 +1,147 @@
+import { useApolloClient, useQuery } from "@apollo/client";
+import {
+ CrudContextMenu,
+ type GridColDef,
+ StackLink,
+ useBufferedRowCount,
+ useDataGridRemote,
+ usePersistentColumnState,
+ useStackSwitchApi,
+} from "@comet/admin";
+import { Edit as EditIcon } from "@comet/admin-icons";
+import { useContentScope } from "@comet/cms-admin";
+import { IconButton } from "@mui/material";
+import { DataGridPro, type GridRowOrderChangeParams, type GridSlotsComponent } from "@mui/x-data-grid-pro";
+import { useMemo } from "react";
+import { useIntl } from "react-intl";
+
+import { deleteProductCategoryMutation, productCategoriesQuery, updateProductCategoryPositionMutation } from "./ProductCategoriesDataGrid.gql";
+import {
+ type GQLDeleteProductCategoryMutation,
+ type GQLDeleteProductCategoryMutationVariables,
+ type GQLProductCategoriesGridItemFragment,
+ type GQLProductCategoriesGridQuery,
+ type GQLProductCategoriesGridQueryVariables,
+ type GQLUpdateProductCategoryPositionMutation,
+ type GQLUpdateProductCategoryPositionMutationVariables,
+} from "./ProductCategoriesDataGrid.gql.generated";
+import { ProductCategoriesDataGridToolbar } from "./toolbar/ProductCategoriesDataGridToolbar";
+
+export function ProductCategoriesDataGrid() {
+ const client = useApolloClient();
+ const intl = useIntl();
+ const { scope } = useContentScope();
+ const dataGridProps = {
+ ...useDataGridRemote({
+ queryParamsPrefix: "productCategories",
+ }),
+ ...usePersistentColumnState("ProductCategoriesDataGrid"),
+ };
+ const stackSwitchApi = useStackSwitchApi();
+
+ const handleRowClick = (params: { row: { id: string } }) => {
+ stackSwitchApi.activatePage("edit", params.row.id);
+ };
+
+ const handleRowOrderChange = async ({ row: { id }, targetIndex }: GridRowOrderChangeParams) => {
+ await client.mutate({
+ mutation: updateProductCategoryPositionMutation,
+ variables: { id, input: { position: targetIndex + 1 } },
+ awaitRefetchQueries: true,
+ refetchQueries: [productCategoriesQuery],
+ });
+ };
+
+ const columns: GridColDef[] = useMemo(
+ () => [
+ {
+ field: "name",
+ headerName: intl.formatMessage({ id: "productCategory.name", defaultMessage: "Name" }),
+ filterable: false,
+ sortable: false,
+ flex: 1,
+ minWidth: 150,
+ },
+ {
+ field: "slug",
+ headerName: intl.formatMessage({ id: "productCategory.slug", defaultMessage: "Slug" }),
+ filterable: false,
+ sortable: false,
+ width: 200,
+ },
+ {
+ field: "parentCategory",
+ headerName: intl.formatMessage({ id: "productCategory.parentCategory", defaultMessage: "Parent Category" }),
+ filterable: false,
+ sortable: false,
+ flex: 1,
+ minWidth: 150,
+ valueGetter: (_value: unknown, row: GQLProductCategoriesGridItemFragment) => row.parentCategory?.name,
+ },
+ {
+ field: "actions",
+ headerName: "",
+ sortable: false,
+ filterable: false,
+ type: "actions",
+ align: "right",
+ pinned: "right",
+ width: 84,
+ renderCell: (params) => {
+ return (
+ <>
+
+
+
+ {
+ await client.mutate({
+ mutation: deleteProductCategoryMutation,
+ variables: { id: params.row.id },
+ });
+ }}
+ refetchQueries={[productCategoriesQuery]}
+ />
+ >
+ );
+ },
+ },
+ ],
+ [intl, client],
+ );
+
+ const { data, loading, error } = useQuery(productCategoriesQuery, {
+ variables: {
+ scope,
+ sort: [{ field: "position", direction: "ASC" as const }],
+ offset: 0,
+ limit: 100,
+ },
+ });
+
+ const rowCount = useBufferedRowCount(data?.productCategories.totalCount);
+ if (error) throw error;
+
+ const rows =
+ data?.productCategories.nodes.map((node) => ({
+ ...node,
+ __reorder__: node.name,
+ })) ?? [];
+
+ return (
+
+ );
+}
diff --git a/admin/src/products/components/productCategoriesDataGrid/toolbar/ProductCategoriesDataGridToolbar.tsx b/admin/src/products/components/productCategoriesDataGrid/toolbar/ProductCategoriesDataGridToolbar.tsx
new file mode 100644
index 000000000..cb526c454
--- /dev/null
+++ b/admin/src/products/components/productCategoriesDataGrid/toolbar/ProductCategoriesDataGridToolbar.tsx
@@ -0,0 +1,14 @@
+import { Button, DataGridToolbar, FillSpace, StackLink } from "@comet/admin";
+import { Add as AddIcon } from "@comet/admin-icons";
+import { FormattedMessage } from "react-intl";
+
+export function ProductCategoriesDataGridToolbar() {
+ return (
+
+
+ } component={StackLink} pageName="add" payload="add">
+
+
+
+ );
+}
diff --git a/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts
new file mode 100644
index 000000000..53f7e9439
--- /dev/null
+++ b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts
@@ -0,0 +1,19 @@
+import { gql } from "@apollo/client";
+
+const productCategoryAsyncAutocompleteFieldFragment = gql`
+ fragment ProductCategoryAsyncAutocompleteFieldProductCategory on ProductCategory {
+ id
+ name
+ }
+`;
+
+export const productCategoryAsyncAutocompleteFieldQuery = gql`
+ query ProductCategoryAsyncAutocompleteField($scope: ProductCategoryScopeInput!, $search: String, $filter: ProductCategoryFilter) {
+ productCategories(scope: $scope, search: $search, filter: $filter) {
+ nodes {
+ ...ProductCategoryAsyncAutocompleteFieldProductCategory
+ }
+ }
+ }
+ ${productCategoryAsyncAutocompleteFieldFragment}
+`;
diff --git a/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx
new file mode 100644
index 000000000..27af2cb0d
--- /dev/null
+++ b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx
@@ -0,0 +1,60 @@
+import { useApolloClient } from "@apollo/client";
+import { AsyncAutocompleteField, type AsyncAutocompleteFieldProps } from "@comet/admin";
+import { useContentScope } from "@comet/cms-admin";
+import { type FunctionComponent } from "react";
+
+import { productCategoryAsyncAutocompleteFieldQuery } from "./ProductCategoryAsyncAutocompleteField.gql";
+import {
+ type GQLProductCategoryAsyncAutocompleteFieldProductCategoryFragment,
+ type GQLProductCategoryAsyncAutocompleteFieldQuery,
+ type GQLProductCategoryAsyncAutocompleteFieldQueryVariables,
+} from "./ProductCategoryAsyncAutocompleteField.gql.generated";
+
+type ProductCategoryAsyncAutocompleteFieldOption = GQLProductCategoryAsyncAutocompleteFieldProductCategoryFragment;
+
+type ProductCategoryAsyncAutocompleteFieldProps = Omit<
+ AsyncAutocompleteFieldProps,
+ "loadOptions"
+> & {
+ excludeId?: string;
+};
+
+export const ProductCategoryAsyncAutocompleteField: FunctionComponent = ({
+ name,
+ excludeId,
+ clearable = true,
+ disabled = false,
+ variant = "horizontal",
+ fullWidth = true,
+ ...restProps
+}) => {
+ const client = useApolloClient();
+ const { scope } = useContentScope();
+
+ return (
+ option.name}
+ isOptionEqualToValue={(option, value) => option.id === value.id}
+ {...restProps}
+ loadOptions={async (search) => {
+ const { data } = await client.query<
+ GQLProductCategoryAsyncAutocompleteFieldQuery,
+ GQLProductCategoryAsyncAutocompleteFieldQueryVariables
+ >({
+ query: productCategoryAsyncAutocompleteFieldQuery,
+ variables: {
+ scope,
+ search,
+ filter: excludeId ? { id: { notEqual: excludeId } } : undefined,
+ },
+ });
+ return data.productCategories.nodes;
+ }}
+ />
+ );
+};
diff --git a/admin/src/products/components/productCategoryForm/ProductCategoryForm.gql.ts b/admin/src/products/components/productCategoryForm/ProductCategoryForm.gql.ts
new file mode 100644
index 000000000..04824b799
--- /dev/null
+++ b/admin/src/products/components/productCategoryForm/ProductCategoryForm.gql.ts
@@ -0,0 +1,57 @@
+import { gql } from "@apollo/client";
+
+export const productCategoryFormFragment = gql`
+ fragment ProductCategoryFormDetails on ProductCategory {
+ name
+ slug
+ parentCategory {
+ id
+ name
+ }
+ }
+`;
+
+export const productCategoryQuery = gql`
+ query ProductCategory($id: ID!) {
+ productCategory(id: $id) {
+ id
+ updatedAt
+ ...ProductCategoryFormDetails
+ }
+ }
+ ${productCategoryFormFragment}
+`;
+
+export const createProductCategoryMutation = gql`
+ mutation CreateProductCategory($scope: ProductCategoryScopeInput!, $input: ProductCategoryInput!) {
+ createProductCategory(scope: $scope, input: $input) {
+ productCategory {
+ id
+ updatedAt
+ ...ProductCategoryFormDetails
+ }
+ errors {
+ code
+ field
+ }
+ }
+ }
+ ${productCategoryFormFragment}
+`;
+
+export const updateProductCategoryMutation = gql`
+ mutation UpdateProductCategory($id: ID!, $input: ProductCategoryUpdateInput!) {
+ updateProductCategory(id: $id, input: $input) {
+ productCategory {
+ id
+ updatedAt
+ ...ProductCategoryFormDetails
+ }
+ errors {
+ code
+ field
+ }
+ }
+ }
+ ${productCategoryFormFragment}
+`;
diff --git a/admin/src/products/components/productCategoryForm/ProductCategoryForm.tsx b/admin/src/products/components/productCategoryForm/ProductCategoryForm.tsx
new file mode 100644
index 000000000..800b883e1
--- /dev/null
+++ b/admin/src/products/components/productCategoryForm/ProductCategoryForm.tsx
@@ -0,0 +1,174 @@
+import { useApolloClient, useQuery } from "@apollo/client";
+import { FieldSet, filterByFragment, FinalForm, type FinalFormSubmitEvent, Loading, TextField, useFormApiRef, useStackSwitchApi } from "@comet/admin";
+import { queryUpdatedAt, resolveHasSaveConflict, useContentScope, useFormSaveConflict } from "@comet/cms-admin";
+import { type GQLProductCategoryValidationErrorCode } from "@src/graphql.generated";
+import { FORM_ERROR, type FormApi } from "final-form";
+import isEqual from "lodash.isequal";
+import { type ReactNode, useMemo } from "react";
+import { FormattedMessage } from "react-intl";
+
+import { ProductCategoryAsyncAutocompleteField } from "../productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField";
+import {
+ createProductCategoryMutation,
+ productCategoryFormFragment,
+ productCategoryQuery,
+ updateProductCategoryMutation,
+} from "./ProductCategoryForm.gql";
+import {
+ type GQLCreateProductCategoryMutation,
+ type GQLCreateProductCategoryMutationVariables,
+ type GQLProductCategoryFormDetailsFragment,
+ type GQLProductCategoryQuery,
+ type GQLProductCategoryQueryVariables,
+ type GQLUpdateProductCategoryMutation,
+ type GQLUpdateProductCategoryMutationVariables,
+} from "./ProductCategoryForm.gql.generated";
+
+type FormValues = GQLProductCategoryFormDetailsFragment;
+
+interface FormProps {
+ id?: string;
+}
+
+const submissionErrorMessages: Record = {
+ SLUG_ALREADY_EXISTS: ,
+};
+
+export function ProductCategoryForm({ id }: FormProps) {
+ const client = useApolloClient();
+ const mode = id ? "edit" : "add";
+ const formApiRef = useFormApiRef();
+ const stackSwitchApi = useStackSwitchApi();
+ const { scope } = useContentScope();
+
+ const { data, error, loading, refetch } = useQuery(
+ productCategoryQuery,
+ id ? { variables: { id } } : { skip: true },
+ );
+
+ const initialValues = useMemo>(
+ () =>
+ data?.productCategory
+ ? {
+ ...filterByFragment(productCategoryFormFragment, data.productCategory),
+ }
+ : {},
+ [data],
+ );
+
+ const saveConflict = useFormSaveConflict({
+ checkConflict: async () => {
+ const updatedAt = await queryUpdatedAt(client, "productCategory", id);
+ return resolveHasSaveConflict(data?.productCategory.updatedAt, updatedAt);
+ },
+ formApiRef,
+ loadLatestVersion: async () => {
+ await refetch();
+ },
+ });
+
+ const handleSubmit = async (formValues: FormValues, form: FormApi, event: FinalFormSubmitEvent) => {
+ if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected");
+
+ const output = {
+ name: formValues.name,
+ slug: formValues.slug,
+ parentCategory: formValues.parentCategory ? formValues.parentCategory.id : null,
+ };
+
+ if (mode === "edit") {
+ if (!id) throw new Error();
+ const { data: mutationResponse } = await client.mutate({
+ mutation: updateProductCategoryMutation,
+ variables: { id, input: output },
+ });
+
+ if (mutationResponse?.updateProductCategory.errors.length) {
+ return mutationResponse.updateProductCategory.errors.reduce(
+ (submissionErrors: Record, error: { code: GQLProductCategoryValidationErrorCode; field?: string | null }) => {
+ const errorMessage = submissionErrorMessages[error.code];
+ if (error.field) {
+ submissionErrors[error.field] = errorMessage;
+ } else {
+ submissionErrors[FORM_ERROR] = errorMessage;
+ }
+ return submissionErrors;
+ },
+ {} as Record,
+ );
+ }
+ } else {
+ const { data: mutationResponse } = await client.mutate({
+ mutation: createProductCategoryMutation,
+ variables: { input: output, scope },
+ });
+
+ if (mutationResponse?.createProductCategory.errors.length) {
+ return mutationResponse.createProductCategory.errors.reduce(
+ (submissionErrors: Record, error: { code: GQLProductCategoryValidationErrorCode; field?: string | null }) => {
+ const errorMessage = submissionErrorMessages[error.code];
+ if (error.field) {
+ submissionErrors[error.field] = errorMessage;
+ } else {
+ submissionErrors[FORM_ERROR] = errorMessage;
+ }
+ return submissionErrors;
+ },
+ {} as Record,
+ );
+ }
+
+ const newId = mutationResponse?.createProductCategory.productCategory?.id;
+ if (newId) {
+ setTimeout(() => stackSwitchApi.activatePage("edit", newId));
+ }
+ }
+ };
+
+ if (error) throw error;
+ if (loading) return ;
+
+ return (
+
+ apiRef={formApiRef}
+ onSubmit={handleSubmit}
+ mode={mode}
+ initialValues={initialValues}
+ initialValuesEqual={isEqual}
+ subscription={{}}
+ >
+ {() => (
+ <>
+ {saveConflict.dialogs}
+ }>
+ }
+ />
+ }
+ helperText={
+
+ }
+ />
+ }
+ excludeId={id}
+ />
+
+ >
+ )}
+
+ );
+}
diff --git a/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.gql.ts b/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.gql.ts
new file mode 100644
index 000000000..5f14a181c
--- /dev/null
+++ b/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.gql.ts
@@ -0,0 +1,10 @@
+import { gql } from "@apollo/client";
+
+export const productCategoryToolbarQuery = gql`
+ query ProductCategoryToolbar($id: ID!) {
+ productCategory(id: $id) {
+ id
+ name
+ }
+ }
+`;
diff --git a/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.tsx b/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.tsx
new file mode 100644
index 000000000..1f85b9206
--- /dev/null
+++ b/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.tsx
@@ -0,0 +1,102 @@
+import { useQuery } from "@apollo/client";
+import {
+ FillSpace,
+ Loading,
+ LocalErrorScopeApolloContext,
+ SaveBoundarySaveButton,
+ StackPageTitle,
+ StackToolbar,
+ ToolbarActions,
+ ToolbarAutomaticTitleItem,
+ ToolbarBackButton,
+ ToolbarItem,
+ Tooltip,
+} from "@comet/admin";
+import { Error } from "@comet/admin-icons";
+import { ContentScopeIndicator } from "@comet/cms-admin";
+import { Box, Typography, useTheme } from "@mui/material";
+import { type FunctionComponent, type ReactNode } from "react";
+import { FormattedMessage } from "react-intl";
+
+import { productCategoryToolbarQuery } from "./ProductCategoryToolbar.gql";
+import { type GQLProductCategoryToolbarQuery, type GQLProductCategoryToolbarQueryVariables } from "./ProductCategoryToolbar.gql.generated";
+
+interface ProductCategoryToolbarProps {
+ id?: string;
+ additionalActions?: ReactNode;
+}
+
+export const ProductCategoryToolbar: FunctionComponent = ({ id, additionalActions }) => {
+ const theme = useTheme();
+
+ const { data, loading, error } = useQuery(
+ productCategoryToolbarQuery,
+ id != null
+ ? {
+ variables: { id },
+ context: LocalErrorScopeApolloContext,
+ }
+ : { skip: true },
+ );
+
+ if (loading) {
+ return (
+ }>
+
+
+
+
+ );
+ }
+
+ const title = data?.productCategory.name;
+
+ return (
+
+ }>
+
+
+ {title ? (
+
+
+ {title}
+
+
+ ) : (
+
+ )}
+
+ {error != null && (
+
+
+
+
+
+
+
+
+ >
+ }
+ >
+
+
+
+
+
+ )}
+
+
+
+
+ {additionalActions}
+
+
+
+
+ );
+};
diff --git a/admin/src/products/components/productForm/ProductForm.gql.ts b/admin/src/products/components/productForm/ProductForm.gql.ts
index ddb43ed10..f8181d0ac 100644
--- a/admin/src/products/components/productForm/ProductForm.gql.ts
+++ b/admin/src/products/components/productForm/ProductForm.gql.ts
@@ -11,6 +11,10 @@ export const productFormFragment = gql`
productStatus
publishedAt
isPublished
+ category {
+ id
+ name
+ }
}
`;
diff --git a/admin/src/products/components/productForm/ProductForm.tsx b/admin/src/products/components/productForm/ProductForm.tsx
index 1557fe959..23e5764f2 100644
--- a/admin/src/products/components/productForm/ProductForm.tsx
+++ b/admin/src/products/components/productForm/ProductForm.tsx
@@ -28,6 +28,7 @@ import { validatePositiveNumber } from "@src/common/validators/validatePositiveN
import { validateSkuFormat } from "@src/common/validators/validateSkuFormat";
import { validateSlug } from "@src/common/validators/validateSlug";
import { type GQLProductValidationErrorCode } from "@src/graphql.generated";
+import { ProductCategoryAsyncAutocompleteField } from "@src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField";
import { ProductStatusSelectField } from "@src/products/components/productStatusSelectField/ProductStatusSelectField";
import { ProductTypeSelectField } from "@src/products/components/productTypeSelectField/ProductTypeSelectField";
import { FORM_ERROR, type FormApi } from "final-form";
@@ -113,6 +114,7 @@ export function ProductForm({ id }: FormProps) {
...formValues,
publishedAt: formValues.publishedAt ? formValues.publishedAt.toISOString() : null,
mainImage: rootBlocks.mainImage.state2Output(formValues.mainImage),
+ category: formValues.category ? formValues.category.id : null,
};
if (mode === "edit") {
@@ -239,6 +241,10 @@ export function ProductForm({ id }: FormProps) {
name="productType"
label={}
/>
+ }
+ />
}>
row.category?.name,
+ filterOperators: ProductCategoryFilterOperators,
+ },
{
field: "productType",
headerName: intl.formatMessage({ id: "product.productType", defaultMessage: "Type" }),
diff --git a/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.gql.ts b/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.gql.ts
new file mode 100644
index 000000000..4c75ee2a6
--- /dev/null
+++ b/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.gql.ts
@@ -0,0 +1,12 @@
+import { gql } from "@apollo/client";
+
+export const productCategoryFilterQuery = gql`
+ query ProductCategoryFilter($scope: ProductCategoryScopeInput!, $offset: Int!, $limit: Int!, $search: String) {
+ productCategories(scope: $scope, offset: $offset, limit: $limit, search: $search) {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+`;
diff --git a/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.tsx b/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.tsx
new file mode 100644
index 000000000..c7e2b7b01
--- /dev/null
+++ b/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.tsx
@@ -0,0 +1,99 @@
+import { useQuery } from "@apollo/client";
+import { ClearInputAdornment } from "@comet/admin";
+import { useContentScope } from "@comet/cms-admin";
+import Autocomplete from "@mui/material/Autocomplete";
+import { type GridFilterInputValueProps, type GridFilterOperator, useGridRootProps } from "@mui/x-data-grid-pro";
+import { useCallback, useState } from "react";
+import { useIntl } from "react-intl";
+import { useDebounce } from "use-debounce";
+
+import { productCategoryFilterQuery } from "./ProductCategoryFilter.gql";
+import { type GQLProductCategoryFilterQuery, type GQLProductCategoryFilterQueryVariables } from "./ProductCategoryFilter.gql.generated";
+
+function ProductCategoryFilter({ item, applyValue, apiRef }: GridFilterInputValueProps) {
+ const intl = useIntl();
+ const [search, setSearch] = useState(undefined);
+ const [debouncedSearch] = useDebounce(search, 500);
+ const rootProps = useGridRootProps();
+ const { scope } = useContentScope();
+
+ const { data } = useQuery(productCategoryFilterQuery, {
+ variables: {
+ scope,
+ offset: 0,
+ limit: 10,
+ search: debouncedSearch,
+ },
+ });
+
+ const handleApplyValue = useCallback(
+ (value: string | undefined) => {
+ applyValue({
+ ...item,
+ id: item.id,
+ operator: "equals",
+ value,
+ });
+ },
+ [applyValue, item],
+ );
+
+ return (
+ x}
+ disableClearable
+ isOptionEqualToValue={(option, value) => option.id == value}
+ getOptionLabel={(option) => {
+ return option.name ?? data?.productCategories.nodes.find((c) => c.id === option)?.name ?? option;
+ }}
+ onChange={(event, value, reason) => {
+ handleApplyValue(value ? value.id : undefined);
+ }}
+ renderInput={(params) => (
+ {
+ setSearch(event.target.value);
+ }}
+ label={apiRef.current.getLocaleText("filterPanelInputLabel")}
+ slotProps={{
+ inputLabel: { shrink: true },
+ input: {
+ ...params.InputProps,
+ endAdornment: (
+ <>
+ handleApplyValue(undefined)}
+ />
+ {params.InputProps.endAdornment}
+ >
+ ),
+ },
+ }}
+ />
+ )}
+ />
+ );
+}
+
+export const ProductCategoryFilterOperators: GridFilterOperator[] = [
+ {
+ value: "equals",
+ getApplyFilterFn: () => {
+ throw new Error("not implemented, we filter server side");
+ },
+ InputComponent: ProductCategoryFilter,
+ },
+];
diff --git a/api/schema.gql b/api/schema.gql
index 1207d12ee..b7f443761 100644
--- a/api/schema.gql
+++ b/api/schema.gql
@@ -26,6 +26,11 @@ input CreateDamFolderInput {
parentId: ID
}
+type CreateProductCategoryPayload {
+ errors: [ProductCategoryValidationError!]!
+ productCategory: ProductCategory
+}
+
type CreateProductPayload {
errors: [ProductValidationError!]!
product: Product
@@ -335,6 +340,12 @@ input LinkInput {
content: LinkBlockInput!
}
+input ManyToOneFilter {
+ equal: ID
+ isAnyOf: [ID!]
+ notEqual: ID
+}
+
type MappedFile {
copy: DamFile!
rootFile: DamFile!
@@ -359,6 +370,7 @@ type Mutation {
createDamMediaAlternative(alternative: ID!, for: ID!, input: DamMediaAlternativeInput!): DamMediaAlternative!
createPageTreeNode(category: String!, input: PageTreeNodeCreateInput!, scope: PageTreeNodeScopeInput!): PageTreeNode!
createProduct(input: ProductInput!, scope: ProductScopeInput!): CreateProductPayload!
+ createProductCategory(input: ProductCategoryInput!, scope: ProductCategoryScopeInput!): CreateProductCategoryPayload!
createRedirect(input: RedirectInput!, scope: RedirectScopeInput!): Redirect!
currentUserSignOut: String!
deleteDamFile(id: ID!): Boolean!
@@ -366,6 +378,7 @@ type Mutation {
deleteDamMediaAlternative(id: ID!): Boolean!
deletePageTreeNode(id: ID!): Boolean!
deleteProduct(id: ID!): Boolean!
+ deleteProductCategory(id: ID!): Boolean!
deleteRedirect(id: ID!): Boolean!
importDamFileByDownload(input: UpdateDamFileInput!, scope: DamScopeInput! = {}, url: String!): DamFile!
moveDamFiles(fileIds: [ID!]!, targetFolderId: ID): [DamFile!]!
@@ -385,6 +398,7 @@ type Mutation {
updatePageTreeNodeSlug(id: ID!, slug: String!): PageTreeNode!
updatePageTreeNodeVisibility(id: ID!, input: PageTreeNodeUpdateVisibilityInput!): PageTreeNode!
updateProduct(id: ID!, input: ProductUpdateInput!): UpdateProductPayload!
+ updateProductCategory(id: ID!, input: ProductCategoryUpdateInput!): UpdateProductCategoryPayload!
updateRedirect(id: ID!, input: RedirectInput!, lastUpdatedAt: DateTime): Redirect!
updateRedirectActiveness(id: ID!, input: RedirectUpdateActivenessInput!): Redirect!
userPermissionsCreatePermission(input: UserPermissionInput!, userId: String!): UserPermission!
@@ -533,6 +547,11 @@ type PaginatedPageTreeNodes {
totalCount: Int!
}
+type PaginatedProductCategories {
+ nodes: [ProductCategory!]!
+ totalCount: Int!
+}
+
type PaginatedProducts {
nodes: [Product!]!
totalCount: Int!
@@ -558,6 +577,7 @@ enum Permission {
impersonation
pageTree
prelogin
+ productCategories
products
sitePreview
translation
@@ -572,6 +592,7 @@ input PermissionFilter {
}
type Product {
+ category: ProductCategory
createdAt: DateTime!
description: String
domain: String!
@@ -589,8 +610,75 @@ type Product {
updatedAt: DateTime!
}
+type ProductCategory {
+ createdAt: DateTime!
+ domain: String!
+ id: ID!
+ language: String!
+ name: String!
+ parentCategory: ProductCategory
+ position: Int!
+ slug: String!
+ updatedAt: DateTime!
+}
+
+input ProductCategoryFilter {
+ and: [ProductCategoryFilter!]
+ createdAt: DateTimeFilter
+ id: IdFilter
+ name: StringFilter
+ or: [ProductCategoryFilter!]
+ parentCategory: ManyToOneFilter
+ position: NumberFilter
+ slug: StringFilter
+ updatedAt: DateTimeFilter
+}
+
+input ProductCategoryInput {
+ name: String!
+ parentCategory: ID
+ position: Int
+ slug: String!
+}
+
+input ProductCategoryScopeInput {
+ domain: String!
+ language: String!
+}
+
+input ProductCategorySort {
+ direction: SortDirection! = ASC
+ field: ProductCategorySortField!
+}
+
+enum ProductCategorySortField {
+ createdAt
+ id
+ name
+ position
+ slug
+ updatedAt
+}
+
+input ProductCategoryUpdateInput {
+ name: String
+ parentCategory: ID
+ position: Int
+ slug: String
+}
+
+type ProductCategoryValidationError {
+ code: ProductCategoryValidationErrorCode!
+ field: String
+}
+
+enum ProductCategoryValidationErrorCode {
+ SLUG_ALREADY_EXISTS
+}
+
input ProductFilter {
and: [ProductFilter!]
+ category: ManyToOneFilter
createdAt: DateTimeFilter
id: IdFilter
isPublished: BooleanFilter
@@ -606,6 +694,7 @@ input ProductFilter {
}
input ProductInput {
+ category: ID
description: String
isPublished: Boolean! = false
mainImage: DamImageBlockInput
@@ -668,6 +757,7 @@ input ProductTypeEnumFilter {
}
input ProductUpdateInput {
+ category: ID
description: String
isPublished: Boolean
mainImage: DamImageBlockInput
@@ -718,6 +808,9 @@ type Query {
paginatedRedirects(filter: RedirectFilter, limit: Int! = 25, offset: Int! = 0, scope: RedirectScopeInput!, search: String, sort: [RedirectSort!]): PaginatedRedirects!
product(id: ID!): Product!
productBySlug(scope: ProductScopeInput!, slug: String!): Product
+ productCategories(filter: ProductCategoryFilter, limit: Int! = 25, offset: Int! = 0, scope: ProductCategoryScopeInput!, search: String, sort: [ProductCategorySort!]! = [{direction: ASC, field: position}]): PaginatedProductCategories!
+ productCategory(id: ID!): ProductCategory!
+ productCategoryBySlug(scope: ProductCategoryScopeInput!, slug: String!): ProductCategory
products(filter: ProductFilter, limit: Int! = 25, offset: Int! = 0, scope: ProductScopeInput!, search: String, sort: [ProductSort!]! = [{direction: ASC, field: createdAt}]): PaginatedProducts!
redirect(id: ID!): Redirect!
redirectBySource(scope: RedirectScopeInput!, source: String!, sourceType: RedirectSourceTypeValues!): Redirect
@@ -863,6 +956,11 @@ input UpdateImageFileInput {
cropArea: ImageCropAreaInput
}
+type UpdateProductCategoryPayload {
+ errors: [ProductCategoryValidationError!]!
+ productCategory: ProductCategory
+}
+
type UpdateProductPayload {
errors: [ProductValidationError!]!
product: Product
diff --git a/api/src/app.module.ts b/api/src/app.module.ts
index 474a6d597..f76e8ea7b 100644
--- a/api/src/app.module.ts
+++ b/api/src/app.module.ts
@@ -36,6 +36,7 @@ import { ConfigModule } from "./config/config.module";
import { DamFile } from "./dam/entities/dam-file.entity";
import { DamFolder } from "./dam/entities/dam-folder.entity";
import { MenusModule } from "./menus/menus.module";
+import { ProductCategoriesModule } from "./product-categories/product-categories.module";
import { ProductsModule } from "./products/products.module";
import { StatusModule } from "./status/status.module";
@@ -135,6 +136,7 @@ export class AppModule {
MenusModule,
DependenciesModule,
FootersModule,
+ ProductCategoriesModule,
ProductsModule,
WarningsModule,
...(!config.debug
diff --git a/api/src/auth/app-permission.enum.ts b/api/src/auth/app-permission.enum.ts
index 314073697..47c77c6c2 100644
--- a/api/src/auth/app-permission.enum.ts
+++ b/api/src/auth/app-permission.enum.ts
@@ -1,3 +1,4 @@
export enum AppPermission {
+ productCategories = "productCategories",
products = "products",
}
diff --git a/api/src/db/migrations/Migration20260316063858.ts b/api/src/db/migrations/Migration20260316063858.ts
new file mode 100644
index 000000000..0b09b7230
--- /dev/null
+++ b/api/src/db/migrations/Migration20260316063858.ts
@@ -0,0 +1,15 @@
+import { Migration } from '@mikro-orm/migrations';
+
+export class Migration20260316063858 extends Migration {
+
+ override async up(): Promise {
+ this.addSql(`create table "ProductCategory" ("id" uuid not null, "name" text not null, "slug" text not null, "position" int not null default 0, "parentCategory" uuid null, "domain" text not null, "language" text not null, "createdAt" timestamptz not null, "updatedAt" timestamptz not null, constraint "ProductCategory_pkey" primary key ("id"));`);
+
+ this.addSql(`alter table "ProductCategory" add constraint "ProductCategory_parentCategory_foreign" foreign key ("parentCategory") references "ProductCategory" ("id") on update cascade on delete set null;`);
+ }
+
+ override async down(): Promise {
+ this.addSql(`drop table if exists "ProductCategory" cascade;`);
+ }
+
+}
diff --git a/api/src/db/migrations/Migration20260316065244.ts b/api/src/db/migrations/Migration20260316065244.ts
new file mode 100644
index 000000000..3f296105b
--- /dev/null
+++ b/api/src/db/migrations/Migration20260316065244.ts
@@ -0,0 +1,15 @@
+import { Migration } from '@mikro-orm/migrations';
+
+export class Migration20260316065244 extends Migration {
+
+ override async up(): Promise {
+ this.addSql(`alter table "Product" add column "category" uuid null;`);
+ this.addSql(`alter table "Product" add constraint "Product_category_foreign" foreign key ("category") references "ProductCategory" ("id") on update cascade on delete set null;`);
+ }
+
+ override async down(): Promise {
+ this.addSql(`alter table "Product" drop constraint "Product_category_foreign";`);
+ this.addSql(`alter table "Product" drop column "category";`);
+ }
+
+}
diff --git a/api/src/product-categories/dto/paginated-product-categories.ts b/api/src/product-categories/dto/paginated-product-categories.ts
new file mode 100644
index 000000000..21e4a2262
--- /dev/null
+++ b/api/src/product-categories/dto/paginated-product-categories.ts
@@ -0,0 +1,6 @@
+import { PaginatedResponseFactory } from "@comet/cms-api";
+import { ObjectType } from "@nestjs/graphql";
+import { ProductCategory } from "@src/product-categories/entities/product-category.entity";
+
+@ObjectType()
+export class PaginatedProductCategories extends PaginatedResponseFactory.create(ProductCategory) {}
diff --git a/api/src/product-categories/dto/product-categories.args.ts b/api/src/product-categories/dto/product-categories.args.ts
new file mode 100644
index 000000000..aa3d6bef0
--- /dev/null
+++ b/api/src/product-categories/dto/product-categories.args.ts
@@ -0,0 +1,31 @@
+import { OffsetBasedPaginationArgs, SortDirection } from "@comet/cms-api";
+import { ArgsType, Field } from "@nestjs/graphql";
+import { ProductCategoryFilter } from "@src/product-categories/dto/product-category.filter";
+import { ProductCategorySort, ProductCategorySortField } from "@src/product-categories/dto/product-category.sort";
+import { ProductCategoryScope } from "@src/product-categories/dto/product-category-scope.input";
+import { Type } from "class-transformer";
+import { IsOptional, IsString, ValidateNested } from "class-validator";
+
+@ArgsType()
+export class ProductCategoriesArgs extends OffsetBasedPaginationArgs {
+ @Field(() => ProductCategoryScope)
+ @ValidateNested()
+ @Type(() => ProductCategoryScope)
+ scope: ProductCategoryScope;
+
+ @Field({ nullable: true })
+ @IsOptional()
+ @IsString()
+ search?: string;
+
+ @Field(() => ProductCategoryFilter, { nullable: true })
+ @ValidateNested()
+ @Type(() => ProductCategoryFilter)
+ @IsOptional()
+ filter?: ProductCategoryFilter;
+
+ @Field(() => [ProductCategorySort], { defaultValue: [{ field: ProductCategorySortField.position, direction: SortDirection.ASC }] })
+ @ValidateNested({ each: true })
+ @Type(() => ProductCategorySort)
+ sort: ProductCategorySort[];
+}
diff --git a/api/src/product-categories/dto/product-category-scope.input.ts b/api/src/product-categories/dto/product-category-scope.input.ts
new file mode 100644
index 000000000..184347b66
--- /dev/null
+++ b/api/src/product-categories/dto/product-category-scope.input.ts
@@ -0,0 +1,14 @@
+import { Field, InputType, ObjectType } from "@nestjs/graphql";
+import { IsString } from "class-validator";
+
+@ObjectType()
+@InputType("ProductCategoryScopeInput")
+export class ProductCategoryScope {
+ @Field()
+ @IsString()
+ domain: string;
+
+ @Field()
+ @IsString()
+ language: string;
+}
diff --git a/api/src/product-categories/dto/product-category.filter.ts b/api/src/product-categories/dto/product-category.filter.ts
new file mode 100644
index 000000000..38cee07ff
--- /dev/null
+++ b/api/src/product-categories/dto/product-category.filter.ts
@@ -0,0 +1,61 @@
+import { DateTimeFilter, IdFilter, ManyToOneFilter, NumberFilter, StringFilter } from "@comet/cms-api";
+import { Field, InputType } from "@nestjs/graphql";
+import { Type } from "class-transformer";
+import { IsOptional, ValidateNested } from "class-validator";
+
+@InputType()
+export class ProductCategoryFilter {
+ @Field(() => IdFilter, { nullable: true })
+ @ValidateNested()
+ @IsOptional()
+ @Type(() => IdFilter)
+ id?: IdFilter;
+
+ @Field(() => StringFilter, { nullable: true })
+ @ValidateNested()
+ @IsOptional()
+ @Type(() => StringFilter)
+ name?: StringFilter;
+
+ @Field(() => StringFilter, { nullable: true })
+ @ValidateNested()
+ @IsOptional()
+ @Type(() => StringFilter)
+ slug?: StringFilter;
+
+ @Field(() => NumberFilter, { nullable: true })
+ @ValidateNested()
+ @IsOptional()
+ @Type(() => NumberFilter)
+ position?: NumberFilter;
+
+ @Field(() => ManyToOneFilter, { nullable: true })
+ @ValidateNested()
+ @IsOptional()
+ @Type(() => ManyToOneFilter)
+ parentCategory?: ManyToOneFilter;
+
+ @Field(() => DateTimeFilter, { nullable: true })
+ @ValidateNested()
+ @IsOptional()
+ @Type(() => DateTimeFilter)
+ createdAt?: DateTimeFilter;
+
+ @Field(() => DateTimeFilter, { nullable: true })
+ @ValidateNested()
+ @IsOptional()
+ @Type(() => DateTimeFilter)
+ updatedAt?: DateTimeFilter;
+
+ @Field(() => [ProductCategoryFilter], { nullable: true })
+ @Type(() => ProductCategoryFilter)
+ @ValidateNested({ each: true })
+ @IsOptional()
+ and?: ProductCategoryFilter[];
+
+ @Field(() => [ProductCategoryFilter], { nullable: true })
+ @Type(() => ProductCategoryFilter)
+ @ValidateNested({ each: true })
+ @IsOptional()
+ or?: ProductCategoryFilter[];
+}
diff --git a/api/src/product-categories/dto/product-category.input.ts b/api/src/product-categories/dto/product-category.input.ts
new file mode 100644
index 000000000..820171881
--- /dev/null
+++ b/api/src/product-categories/dto/product-category.input.ts
@@ -0,0 +1,30 @@
+import { IsSlug, PartialType } from "@comet/cms-api";
+import { Field, ID, InputType, Int } from "@nestjs/graphql";
+import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from "class-validator";
+
+@InputType()
+export class ProductCategoryInput {
+ @IsNotEmpty()
+ @IsString()
+ @Field()
+ name: string;
+
+ @IsNotEmpty()
+ @IsSlug()
+ @Field()
+ slug: string;
+
+ @IsOptional()
+ @Min(1)
+ @IsInt()
+ @Field(() => Int, { nullable: true })
+ position?: number;
+
+ @IsOptional()
+ @IsUUID()
+ @Field(() => ID, { nullable: true })
+ parentCategory?: string;
+}
+
+@InputType()
+export class ProductCategoryUpdateInput extends PartialType(ProductCategoryInput) {}
diff --git a/api/src/product-categories/dto/product-category.sort.ts b/api/src/product-categories/dto/product-category.sort.ts
new file mode 100644
index 000000000..0965d747d
--- /dev/null
+++ b/api/src/product-categories/dto/product-category.sort.ts
@@ -0,0 +1,25 @@
+import { SortDirection } from "@comet/cms-api";
+import { Field, InputType, registerEnumType } from "@nestjs/graphql";
+import { IsEnum } from "class-validator";
+
+export enum ProductCategorySortField {
+ name = "name",
+ slug = "slug",
+ position = "position",
+ createdAt = "createdAt",
+ updatedAt = "updatedAt",
+ id = "id",
+}
+
+registerEnumType(ProductCategorySortField, { name: "ProductCategorySortField" });
+
+@InputType()
+export class ProductCategorySort {
+ @Field(() => ProductCategorySortField)
+ @IsEnum(ProductCategorySortField)
+ field: ProductCategorySortField;
+
+ @Field(() => SortDirection, { defaultValue: SortDirection.ASC })
+ @IsEnum(SortDirection)
+ direction: SortDirection = SortDirection.ASC;
+}
diff --git a/api/src/product-categories/entities/product-category.entity.ts b/api/src/product-categories/entities/product-category.entity.ts
new file mode 100644
index 000000000..05d7f8d05
--- /dev/null
+++ b/api/src/product-categories/entities/product-category.entity.ts
@@ -0,0 +1,50 @@
+import { ScopedEntity } from "@comet/cms-api";
+import { BaseEntity, Entity, ManyToOne, OptionalProps, PrimaryKey, Property, Ref } from "@mikro-orm/postgresql";
+import { Field, ID, Int, ObjectType } from "@nestjs/graphql";
+import { v4 as uuid } from "uuid";
+
+@Entity()
+@ObjectType()
+@ScopedEntity((productCategory) => ({
+ domain: productCategory.domain,
+ language: productCategory.language,
+}))
+export class ProductCategory extends BaseEntity {
+ [OptionalProps]?: "createdAt" | "updatedAt" | "position";
+
+ @PrimaryKey({ type: "uuid" })
+ @Field(() => ID)
+ id: string = uuid();
+
+ @Property({ type: "text" })
+ @Field()
+ name: string;
+
+ @Property({ type: "text" })
+ @Field()
+ slug: string;
+
+ @Property({ type: "integer" })
+ @Field(() => Int)
+ position: number = 0;
+
+ @ManyToOne(() => ProductCategory, { ref: true, nullable: true })
+ @Field(() => ProductCategory, { nullable: true })
+ parentCategory?: Ref;
+
+ @Property({ type: "text" })
+ @Field()
+ domain: string;
+
+ @Property({ type: "text" })
+ @Field()
+ language: string;
+
+ @Property({ type: "timestamp with time zone" })
+ @Field()
+ createdAt: Date = new Date();
+
+ @Property({ type: "timestamp with time zone", onUpdate: () => new Date() })
+ @Field()
+ updatedAt: Date = new Date();
+}
diff --git a/api/src/product-categories/product-categories.module.ts b/api/src/product-categories/product-categories.module.ts
new file mode 100644
index 000000000..d41c339a5
--- /dev/null
+++ b/api/src/product-categories/product-categories.module.ts
@@ -0,0 +1,13 @@
+import { MikroOrmModule } from "@mikro-orm/nestjs";
+import { Module } from "@nestjs/common";
+
+import { ProductCategory } from "./entities/product-category.entity";
+import { ProductCategoriesService } from "./product-categories.service";
+import { ProductCategoryResolver } from "./product-category.resolver";
+
+@Module({
+ imports: [MikroOrmModule.forFeature([ProductCategory])],
+ providers: [ProductCategoriesService, ProductCategoryResolver],
+ exports: [ProductCategoriesService],
+})
+export class ProductCategoriesModule {}
diff --git a/api/src/product-categories/product-categories.service.ts b/api/src/product-categories/product-categories.service.ts
new file mode 100644
index 000000000..7a3c95d09
--- /dev/null
+++ b/api/src/product-categories/product-categories.service.ts
@@ -0,0 +1,207 @@
+import { CurrentUser, gqlArgsToMikroOrmQuery, gqlSortToMikroOrmOrderBy } from "@comet/cms-api";
+import { EntityManager, FilterQuery, FindOptions, raw, Reference } from "@mikro-orm/postgresql";
+import { Injectable } from "@nestjs/common";
+import { Field, ObjectType, registerEnumType } from "@nestjs/graphql";
+import { ProductCategoriesArgs } from "@src/product-categories/dto/product-categories.args";
+import { ProductCategoryInput, ProductCategoryUpdateInput } from "@src/product-categories/dto/product-category.input";
+import { ProductCategoryScope } from "@src/product-categories/dto/product-category-scope.input";
+import { ProductCategory } from "@src/product-categories/entities/product-category.entity";
+
+import { PaginatedProductCategories } from "./dto/paginated-product-categories";
+
+enum ProductCategoryValidationErrorCode {
+ SLUG_ALREADY_EXISTS = "SLUG_ALREADY_EXISTS",
+}
+
+registerEnumType(ProductCategoryValidationErrorCode, { name: "ProductCategoryValidationErrorCode" });
+
+@ObjectType()
+export class ProductCategoryValidationError {
+ @Field({ nullable: true })
+ field?: string;
+
+ @Field(() => ProductCategoryValidationErrorCode)
+ code: ProductCategoryValidationErrorCode;
+}
+
+@Injectable()
+export class ProductCategoriesService {
+ constructor(private readonly entityManager: EntityManager) {}
+
+ async findOneById(id: string): Promise {
+ return this.entityManager.findOneOrFail(ProductCategory, id);
+ }
+
+ async findAll({ scope, search, filter, sort, offset, limit }: ProductCategoriesArgs, fields?: string[]): Promise {
+ const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(ProductCategory));
+ Object.assign(where, scope);
+ const options: FindOptions = { offset, limit };
+ if (sort) {
+ options.orderBy = gqlSortToMikroOrmOrderBy(sort);
+ }
+ const populate: string[] = [];
+ if (fields?.includes("parentCategory")) {
+ populate.push("parentCategory");
+ }
+ if (populate.length > 0) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ options.populate = populate as any;
+ }
+ const [entities, totalCount] = await this.entityManager.findAndCount(ProductCategory, where, options);
+ return new PaginatedProductCategories(entities, totalCount);
+ }
+
+ async findBySlug(scope: ProductCategoryScope, slug: string): Promise {
+ const productCategory = await this.entityManager.findOne(ProductCategory, { slug, ...scope });
+ return productCategory ?? null;
+ }
+
+ async create(
+ scope: ProductCategoryScope,
+ input: ProductCategoryInput,
+ user: CurrentUser,
+ ): Promise<{ productCategory?: ProductCategory; errors: ProductCategoryValidationError[] }> {
+ const errors = await this.validateCreateInput(input, { currentUser: user, scope });
+ if (errors.length > 0) {
+ return { errors };
+ }
+
+ const { parentCategory: parentCategoryInput, ...assignInput } = input;
+ const group = { domain: scope.domain, language: scope.language };
+ const lastPosition = await this.getLastPosition(group);
+ let position = assignInput.position;
+ if (position !== undefined && position < lastPosition + 1) {
+ await this.incrementPositions(group, position);
+ } else {
+ position = lastPosition + 1;
+ }
+
+ const productCategory = this.entityManager.create(ProductCategory, {
+ ...assignInput,
+ ...scope,
+ position,
+ ...(parentCategoryInput
+ ? { parentCategory: Reference.create(await this.entityManager.findOneOrFail(ProductCategory, parentCategoryInput)) }
+ : {}),
+ });
+ await this.entityManager.flush();
+ return { productCategory, errors: [] };
+ }
+
+ async update(
+ id: string,
+ input: ProductCategoryUpdateInput,
+ user: CurrentUser,
+ ): Promise<{ productCategory?: ProductCategory; errors: ProductCategoryValidationError[] }> {
+ const productCategory = await this.entityManager.findOneOrFail(ProductCategory, id);
+
+ const errors = await this.validateUpdateInput(input, { currentUser: user, entity: productCategory });
+ if (errors.length > 0) {
+ return { errors };
+ }
+
+ const { parentCategory: parentCategoryInput, ...assignInput } = input;
+ const group = { domain: productCategory.domain, language: productCategory.language };
+
+ if (assignInput.position !== undefined) {
+ const lastPosition = await this.getLastPosition(group);
+ if (assignInput.position > lastPosition) {
+ assignInput.position = lastPosition;
+ }
+ if (productCategory.position < assignInput.position) {
+ await this.decrementPositions(group, productCategory.position, assignInput.position);
+ } else if (productCategory.position > assignInput.position) {
+ await this.incrementPositions(group, assignInput.position, productCategory.position);
+ }
+ }
+
+ productCategory.assign({ ...assignInput });
+
+ if (parentCategoryInput !== undefined) {
+ productCategory.parentCategory = parentCategoryInput
+ ? Reference.create(await this.entityManager.findOneOrFail(ProductCategory, parentCategoryInput))
+ : undefined;
+ }
+
+ await this.entityManager.flush();
+ return { productCategory, errors: [] };
+ }
+
+ async delete(id: string): Promise {
+ const productCategory = await this.entityManager.findOneOrFail(ProductCategory, id);
+ const group = { domain: productCategory.domain, language: productCategory.language };
+ this.entityManager.remove(productCategory);
+ await this.decrementPositions(group, productCategory.position);
+ await this.entityManager.flush();
+ return true;
+ }
+
+ private async incrementPositions(group: { domain: string; language: string }, lowestPosition: number, highestPosition?: number) {
+ await this.entityManager.nativeUpdate(
+ ProductCategory,
+ {
+ $and: [
+ { position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } },
+ this.getPositionGroupCondition(group),
+ ],
+ },
+ { position: raw("position + 1") },
+ );
+ }
+
+ private async decrementPositions(group: { domain: string; language: string }, lowestPosition: number, highestPosition?: number) {
+ await this.entityManager.nativeUpdate(
+ ProductCategory,
+ {
+ $and: [
+ { position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } },
+ this.getPositionGroupCondition(group),
+ ],
+ },
+ { position: raw("position - 1") },
+ );
+ }
+
+ private async getLastPosition(group: { domain: string; language: string }) {
+ return this.entityManager.count(ProductCategory, this.getPositionGroupCondition(group));
+ }
+
+ private getPositionGroupCondition(group: { domain: string; language: string }): FilterQuery {
+ return { domain: group.domain, language: group.language };
+ }
+
+ private async validateCreateInput(
+ input: ProductCategoryInput,
+ context: { currentUser: CurrentUser; scope: ProductCategoryScope },
+ ): Promise {
+ const errors: ProductCategoryValidationError[] = [];
+
+ const existingSlug = await this.entityManager.findOne(ProductCategory, { slug: input.slug, ...context.scope });
+ if (existingSlug) {
+ errors.push({ field: "slug", code: ProductCategoryValidationErrorCode.SLUG_ALREADY_EXISTS });
+ }
+
+ return errors;
+ }
+
+ private async validateUpdateInput(
+ input: ProductCategoryUpdateInput,
+ context: { currentUser: CurrentUser; entity: ProductCategory },
+ ): Promise {
+ const errors: ProductCategoryValidationError[] = [];
+
+ if (input.slug !== undefined) {
+ const existingSlug = await this.entityManager.findOne(ProductCategory, {
+ slug: input.slug,
+ domain: context.entity.domain,
+ language: context.entity.language,
+ id: { $ne: context.entity.id },
+ });
+ if (existingSlug) {
+ errors.push({ field: "slug", code: ProductCategoryValidationErrorCode.SLUG_ALREADY_EXISTS });
+ }
+ }
+
+ return errors;
+ }
+}
diff --git a/api/src/product-categories/product-category.resolver.ts b/api/src/product-categories/product-category.resolver.ts
new file mode 100644
index 000000000..8365dace0
--- /dev/null
+++ b/api/src/product-categories/product-category.resolver.ts
@@ -0,0 +1,90 @@
+import { AffectedEntity, CurrentUser, extractGraphqlFields, GetCurrentUser, RequiredPermission } from "@comet/cms-api";
+import { Args, Field, ID, Info, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql";
+import { ProductCategoriesArgs } from "@src/product-categories/dto/product-categories.args";
+import { ProductCategoryInput, ProductCategoryUpdateInput } from "@src/product-categories/dto/product-category.input";
+import { ProductCategoryScope } from "@src/product-categories/dto/product-category-scope.input";
+import { ProductCategory } from "@src/product-categories/entities/product-category.entity";
+import { GraphQLResolveInfo } from "graphql";
+
+import { PaginatedProductCategories } from "./dto/paginated-product-categories";
+import { ProductCategoriesService, ProductCategoryValidationError } from "./product-categories.service";
+
+@ObjectType()
+class CreateProductCategoryPayload {
+ @Field(() => ProductCategory, { nullable: true })
+ productCategory?: ProductCategory;
+
+ @Field(() => [ProductCategoryValidationError], { nullable: false })
+ errors: ProductCategoryValidationError[];
+}
+
+@ObjectType()
+class UpdateProductCategoryPayload {
+ @Field(() => ProductCategory, { nullable: true })
+ productCategory?: ProductCategory;
+
+ @Field(() => [ProductCategoryValidationError], { nullable: false })
+ errors: ProductCategoryValidationError[];
+}
+
+@Resolver(() => ProductCategory)
+@RequiredPermission(["productCategories"])
+export class ProductCategoryResolver {
+ constructor(private readonly productCategoriesService: ProductCategoriesService) {}
+
+ @Query(() => ProductCategory)
+ @AffectedEntity(ProductCategory)
+ async productCategory(
+ @Args("id", { type: () => ID })
+ id: string,
+ ): Promise {
+ return this.productCategoriesService.findOneById(id);
+ }
+
+ @Query(() => PaginatedProductCategories)
+ async productCategories(@Args() args: ProductCategoriesArgs, @Info() info: GraphQLResolveInfo): Promise {
+ const fields = extractGraphqlFields(info, { root: "nodes" });
+ return this.productCategoriesService.findAll(args, fields);
+ }
+
+ @Query(() => ProductCategory, { nullable: true })
+ async productCategoryBySlug(
+ @Args("scope", { type: () => ProductCategoryScope }) scope: ProductCategoryScope,
+ @Args("slug") slug: string,
+ ): Promise {
+ return this.productCategoriesService.findBySlug(scope, slug);
+ }
+
+ @Mutation(() => CreateProductCategoryPayload)
+ async createProductCategory(
+ @Args("scope", { type: () => ProductCategoryScope }) scope: ProductCategoryScope,
+ @Args("input", { type: () => ProductCategoryInput }) input: ProductCategoryInput,
+ @GetCurrentUser() user: CurrentUser,
+ ): Promise {
+ return this.productCategoriesService.create(scope, input, user);
+ }
+
+ @Mutation(() => UpdateProductCategoryPayload)
+ @AffectedEntity(ProductCategory)
+ async updateProductCategory(
+ @Args("id", { type: () => ID }) id: string,
+ @Args("input", { type: () => ProductCategoryUpdateInput }) input: ProductCategoryUpdateInput,
+ @GetCurrentUser() user: CurrentUser,
+ ): Promise {
+ return this.productCategoriesService.update(id, input, user);
+ }
+
+ @Mutation(() => Boolean)
+ @AffectedEntity(ProductCategory)
+ async deleteProductCategory(
+ @Args("id", { type: () => ID })
+ id: string,
+ ): Promise {
+ return this.productCategoriesService.delete(id);
+ }
+
+ @ResolveField(() => ProductCategory, { nullable: true })
+ async parentCategory(@Parent() productCategory: ProductCategory): Promise {
+ return productCategory.parentCategory?.loadOrFail();
+ }
+}
diff --git a/api/src/products/dto/product.filter.ts b/api/src/products/dto/product.filter.ts
index f4a7b4189..f343c5eac 100644
--- a/api/src/products/dto/product.filter.ts
+++ b/api/src/products/dto/product.filter.ts
@@ -1,4 +1,4 @@
-import { BooleanFilter, createEnumFilter, DateTimeFilter, IdFilter, NumberFilter, StringFilter } from "@comet/cms-api";
+import { BooleanFilter, createEnumFilter, DateTimeFilter, IdFilter, ManyToOneFilter, NumberFilter, StringFilter } from "@comet/cms-api";
import { Field, InputType } from "@nestjs/graphql";
import { ProductStatus, ProductType } from "@src/products/entities/product.entity";
import { Type } from "class-transformer";
@@ -66,6 +66,12 @@ export class ProductFilter {
@Type(() => ProductTypeFilter)
productType?: typeof ProductTypeFilter;
+ @Field(() => ManyToOneFilter, { nullable: true })
+ @ValidateNested()
+ @IsOptional()
+ @Type(() => ManyToOneFilter)
+ category?: ManyToOneFilter;
+
@Field(() => DateTimeFilter, { nullable: true })
@ValidateNested()
@IsOptional()
diff --git a/api/src/products/dto/product.input.ts b/api/src/products/dto/product.input.ts
index da2b5b32d..c937e8f8b 100644
--- a/api/src/products/dto/product.input.ts
+++ b/api/src/products/dto/product.input.ts
@@ -1,8 +1,8 @@
import { BlockInputInterface, DamImageBlock, isBlockInputInterface, IsSlug, PartialType, RootBlockInputScalar } from "@comet/cms-api";
-import { Field, Float, InputType } from "@nestjs/graphql";
+import { Field, Float, ID, InputType } from "@nestjs/graphql";
import { ProductStatus, ProductType } from "@src/products/entities/product.entity";
import { Transform } from "class-transformer";
-import { IsBoolean, IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
+import { IsBoolean, IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, ValidateNested } from "class-validator";
@InputType()
export class ProductInput {
@@ -51,6 +51,11 @@ export class ProductInput {
@Field(() => ProductType)
productType: ProductType;
+ @IsOptional()
+ @IsUUID()
+ @Field(() => ID, { nullable: true })
+ category?: string;
+
@IsOptional()
@Field(() => RootBlockInputScalar(DamImageBlock), { nullable: true })
@Transform(({ value }) => (isBlockInputInterface(value) ? value : DamImageBlock.blockInputFactory(value)), { toClassOnly: true })
diff --git a/api/src/products/entities/product.entity.ts b/api/src/products/entities/product.entity.ts
index 702fc450b..4a48c082a 100644
--- a/api/src/products/entities/product.entity.ts
+++ b/api/src/products/entities/product.entity.ts
@@ -1,6 +1,7 @@
import { BlockDataInterface, DamImageBlock, RootBlock, RootBlockDataScalar, RootBlockEntity, RootBlockType, ScopedEntity } from "@comet/cms-api";
-import { BaseEntity, Entity, Enum, OptionalProps, PrimaryKey, Property } from "@mikro-orm/postgresql";
+import { BaseEntity, Entity, Enum, ManyToOne, OptionalProps, PrimaryKey, Property, Ref } from "@mikro-orm/postgresql";
import { Field, Float, ID, ObjectType, registerEnumType } from "@nestjs/graphql";
+import { ProductCategory } from "@src/product-categories/entities/product-category.entity";
import { v4 as uuid } from "uuid";
export enum ProductStatus {
@@ -70,6 +71,10 @@ export class Product extends BaseEntity {
@Field(() => ProductType)
productType: ProductType;
+ @ManyToOne(() => ProductCategory, { ref: true, nullable: true })
+ @Field(() => ProductCategory, { nullable: true })
+ category?: Ref;
+
@RootBlock(DamImageBlock)
@Property({ type: new RootBlockType(DamImageBlock), nullable: true })
@Field(() => RootBlockDataScalar(DamImageBlock), { nullable: true })
diff --git a/api/src/products/product.resolver.ts b/api/src/products/product.resolver.ts
index 14cb598fe..623edb82e 100644
--- a/api/src/products/product.resolver.ts
+++ b/api/src/products/product.resolver.ts
@@ -1,9 +1,19 @@
-import { AffectedEntity, CurrentUser, DamImageBlock, GetCurrentUser, RequiredPermission, RootBlockDataScalar } from "@comet/cms-api";
-import { Args, Field, ID, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql";
+import {
+ AffectedEntity,
+ CurrentUser,
+ DamImageBlock,
+ extractGraphqlFields,
+ GetCurrentUser,
+ RequiredPermission,
+ RootBlockDataScalar,
+} from "@comet/cms-api";
+import { Args, Field, ID, Info, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql";
+import { ProductCategory } from "@src/product-categories/entities/product-category.entity";
import { ProductInput, ProductUpdateInput } from "@src/products/dto/product.input";
import { ProductScope } from "@src/products/dto/product-scope.input";
import { ProductsArgs } from "@src/products/dto/products.args";
import { Product } from "@src/products/entities/product.entity";
+import { GraphQLResolveInfo } from "graphql";
import { PaginatedProducts } from "./dto/paginated-products";
import { ProductsService, ProductValidationError } from "./products.service";
@@ -41,11 +51,9 @@ export class ProductResolver {
}
@Query(() => PaginatedProducts)
- async products(
- @Args()
- args: ProductsArgs,
- ): Promise {
- return this.productsService.findAll(args);
+ async products(@Args() args: ProductsArgs, @Info() info: GraphQLResolveInfo): Promise {
+ const fields = extractGraphqlFields(info, { root: "nodes" });
+ return this.productsService.findAll(args, fields);
}
@Query(() => Product, { nullable: true })
@@ -81,6 +89,11 @@ export class ProductResolver {
return this.productsService.delete(id);
}
+ @ResolveField(() => ProductCategory, { nullable: true })
+ async category(@Parent() product: Product): Promise {
+ return product.category?.loadOrFail();
+ }
+
@ResolveField(() => RootBlockDataScalar(DamImageBlock), { nullable: true })
async mainImage(@Parent() product: Product): Promise