diff --git a/.gitignore b/.gitignore index bcd7db157..6f75994e0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules .env.secrets .env.site-configs coverage +.claude \ No newline at end of file diff --git a/admin/src/common/MasterMenu.tsx b/admin/src/common/MasterMenu.tsx index a139d9119..a266ccaf1 100644 --- a/admin/src/common/MasterMenu.tsx +++ b/admin/src/common/MasterMenu.tsx @@ -1,4 +1,4 @@ -import { Assets, Dashboard, PageTree, Snips, Wrench } from "@comet/admin-icons"; +import { Assets, Dashboard, 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 { ProductsPage } from "@src/products/ProductsPage"; import { FormattedMessage } from "react-intl"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -50,6 +51,16 @@ export const masterMenuData: MasterMenuData = [ }, requiredPermission: "pageTree", }, + { + type: "route", + primary: , + icon: , + route: { + path: "/products", + component: ProductsPage, + }, + requiredPermission: "products", + }, { type: "route", primary: , diff --git a/admin/src/common/components/enums/chipIcon/ChipIcon.tsx b/admin/src/common/components/enums/chipIcon/ChipIcon.tsx new file mode 100644 index 000000000..639d1ba8e --- /dev/null +++ b/admin/src/common/components/enums/chipIcon/ChipIcon.tsx @@ -0,0 +1,19 @@ +import { ChevronDown, ThreeDotSaving } from "@comet/admin-icons"; +import { type ChipProps } from "@mui/material"; +import { type FunctionComponent } from "react"; + +type ChipIconProps = { + loading: boolean; + onClick?: ChipProps["onClick"]; +}; + +export const ChipIcon: FunctionComponent = ({ loading, onClick }) => { + if (loading) { + return ; + } + if (onClick) { + return ; + } + + return null; +}; diff --git a/admin/src/common/components/enums/createTranslatableEnum/createTranslatableEnum.tsx b/admin/src/common/components/enums/createTranslatableEnum/createTranslatableEnum.tsx new file mode 100644 index 000000000..22700b9ae --- /dev/null +++ b/admin/src/common/components/enums/createTranslatableEnum/createTranslatableEnum.tsx @@ -0,0 +1,32 @@ +import type { FunctionComponent, ReactNode } from "react"; +import { FormattedMessage, type MessageDescriptor } from "react-intl"; + +type ComponentProps = { + value: T; +}; +type TranslatableEnumResult = { + Component: FunctionComponent>; + messageDescriptorMap: Record; + formattedMessageMap: Record; +}; + +export function createTranslatableEnum(messageDescriptorMap: Record): TranslatableEnumResult { + const formattedMessageMap = Object.keys(messageDescriptorMap).reduce( + (acc, key) => { + const k = key as T; + acc[k] = ; + return acc; + }, + {} as Record, + ); + + const Component: FunctionComponent<{ value: T }> = ({ value }) => { + return formattedMessageMap[value]; + }; + + return { + Component, + messageDescriptorMap, + formattedMessageMap, + }; +} diff --git a/admin/src/common/components/enums/enumChip/EnumChip.tsx b/admin/src/common/components/enums/enumChip/EnumChip.tsx new file mode 100644 index 000000000..c2eb8c35e --- /dev/null +++ b/admin/src/common/components/enums/enumChip/EnumChip.tsx @@ -0,0 +1,50 @@ +import { type ChipProps as MuiChipProps, ListItemText, Menu, MenuItem } from "@mui/material"; +import { type ComponentType, type ReactNode, useState } from "react"; + +export type EnumChipProps = { + chipMap: EnumChipMap; + formattedMessageMap: EnumFormattedMessageMap; + loading?: boolean; + onSelectItem?: (status: T) => void; + sortOrder?: T[]; + value: T; +}; +type EnumChipItemProps = { loading: boolean; onClick?: MuiChipProps["onClick"] }; +type EnumChipMap = Record>; +type EnumFormattedMessageMap = Record; + +function keysFromObject(obj: T): (keyof T)[] { + return Object.keys(obj) as (keyof T)[]; +} + +export function EnumChip({ chipMap, formattedMessageMap, loading = false, onSelectItem, sortOrder = [], value }: EnumChipProps) { + const [anchorElement, setAnchorElement] = useState(null); + + const StatusChip: ComponentType = chipMap[value]; + const open = !!anchorElement; + + return ( + <> + setAnchorElement(event.currentTarget) : undefined} /> + + setAnchorElement(null)} open={open}> + {keysFromObject(formattedMessageMap) + .sort((a, b) => { + return sortOrder.indexOf(a) - sortOrder.indexOf(b); + }) + .map((currentValue) => ( + { + onSelectItem?.(currentValue); + setAnchorElement(null); + }} + > + {formattedMessageMap[currentValue]} + + ))} + + + ); +} diff --git a/admin/src/common/components/enums/messageDescriptorMapToValueOptions/messageDescriptorMapToValueOptions.ts b/admin/src/common/components/enums/messageDescriptorMapToValueOptions/messageDescriptorMapToValueOptions.ts new file mode 100644 index 000000000..6c4f93f89 --- /dev/null +++ b/admin/src/common/components/enums/messageDescriptorMapToValueOptions/messageDescriptorMapToValueOptions.ts @@ -0,0 +1,13 @@ +import { type IntlShape, type MessageDescriptor } from "react-intl"; + +type ValueOption = { + value: T; + label: string; +}; + +export function messageDescriptorMapToValueOptions(map: Record, intl: IntlShape): Array> { + return (Object.entries(map) as Array<[T, MessageDescriptor]>).map(([value, descriptor]) => ({ + value, + label: intl.formatMessage(descriptor), + })); +} diff --git a/admin/src/common/components/enums/recordToOptions/recordToOptions.ts b/admin/src/common/components/enums/recordToOptions/recordToOptions.ts new file mode 100644 index 000000000..8833e7bad --- /dev/null +++ b/admin/src/common/components/enums/recordToOptions/recordToOptions.ts @@ -0,0 +1,13 @@ +import { type ReactNode } from "react"; + +type Option = { + value: T; + label: ReactNode; +}; + +export function recordToOptions(record: Record): Array> { + return (Object.entries(record) as Array<[T, ReactNode]>).map(([value, label]) => ({ + value, + label, + })); +} diff --git a/admin/src/common/validators/validatePositiveNumber.tsx b/admin/src/common/validators/validatePositiveNumber.tsx new file mode 100644 index 000000000..e92191c24 --- /dev/null +++ b/admin/src/common/validators/validatePositiveNumber.tsx @@ -0,0 +1,10 @@ +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; + +export const validatePositiveNumber = (value: number | undefined): ReactElement | undefined => { + if (value == null) return undefined; + if (value <= 0) { + return ; + } + return undefined; +}; diff --git a/admin/src/common/validators/validateSkuFormat.tsx b/admin/src/common/validators/validateSkuFormat.tsx new file mode 100644 index 000000000..dee955531 --- /dev/null +++ b/admin/src/common/validators/validateSkuFormat.tsx @@ -0,0 +1,17 @@ +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; + +const SKU_PATTERN = /^[A-Z]{2,4}-[0-9]{4,8}$/; + +export const validateSkuFormat = (value: string | undefined): ReactElement | undefined => { + if (!value) return undefined; + if (!SKU_PATTERN.test(value)) { + return ( + + ); + } + return undefined; +}; diff --git a/admin/src/common/validators/validateSlug.tsx b/admin/src/common/validators/validateSlug.tsx new file mode 100644 index 000000000..8f1427601 --- /dev/null +++ b/admin/src/common/validators/validateSlug.tsx @@ -0,0 +1,17 @@ +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; + +const SLUG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]*$/; + +export const validateSlug = (value: string | undefined): ReactElement | undefined => { + if (!value) return undefined; + if (!SLUG_PATTERN.test(value)) { + return ( + + ); + } + return undefined; +}; diff --git a/admin/src/products/ProductsPage.tsx b/admin/src/products/ProductsPage.tsx new file mode 100644 index 000000000..b611501a3 --- /dev/null +++ b/admin/src/products/ProductsPage.tsx @@ -0,0 +1,64 @@ +import { + Button, + FillSpace, + SaveBoundary, + Stack, + StackLink, + StackMainContent, + StackPage, + StackSwitch, + StackToolbar, + ToolbarActions, + ToolbarAutomaticTitleItem, + ToolbarBackButton, +} from "@comet/admin"; +import { Add } from "@comet/admin-icons"; +import { ContentScopeIndicator } from "@comet/cms-admin"; +import { type FunctionComponent } from "react"; +import { FormattedMessage } from "react-intl"; + +import { ProductForm } from "./components/productForm/ProductForm"; +import { ProductsGrid } from "./components/productsDataGrid/ProductsGrid"; +import { ProductToolbar } from "./components/productToolbar/ProductToolbar"; + +export const ProductsPage: FunctionComponent = () => { + return ( + }> + + + }> + + + + + + + + + + + + + + + + + + + + + {(id) => ( + + + + + + + )} + + + + ); +}; diff --git a/admin/src/products/components/productForm/ProductForm.gql.ts b/admin/src/products/components/productForm/ProductForm.gql.ts new file mode 100644 index 000000000..ddb43ed10 --- /dev/null +++ b/admin/src/products/components/productForm/ProductForm.gql.ts @@ -0,0 +1,61 @@ +import { gql } from "@apollo/client"; + +export const productFormFragment = gql` + fragment ProductFormDetails on Product { + name + slug + description + sku + price + productType + productStatus + publishedAt + isPublished + } +`; + +export const productQuery = gql` + query Product($id: ID!) { + product(id: $id) { + id + updatedAt + mainImage + ...ProductFormDetails + } + } + ${productFormFragment} +`; + +export const createProductMutation = gql` + mutation CreateProduct($scope: ProductScopeInput!, $input: ProductInput!) { + createProduct(scope: $scope, input: $input) { + product { + id + updatedAt + ...ProductFormDetails + } + errors { + code + field + } + } + } + ${productFormFragment} +`; + +export const updateProductMutation = gql` + mutation UpdateProduct($id: ID!, $input: ProductUpdateInput!) { + updateProduct(id: $id, input: $input) { + product { + id + updatedAt + ...ProductFormDetails + } + errors { + code + field + } + } + } + ${productFormFragment} +`; diff --git a/admin/src/products/components/productForm/ProductForm.tsx b/admin/src/products/components/productForm/ProductForm.tsx new file mode 100644 index 000000000..1557fe959 --- /dev/null +++ b/admin/src/products/components/productForm/ProductForm.tsx @@ -0,0 +1,279 @@ +import { useApolloClient, useQuery } from "@apollo/client"; +import { + Field, + FieldSet, + filterByFragment, + FinalForm, + type FinalFormSubmitEvent, + Future_DateTimePickerField, + Loading, + NumberField, + SwitchField, + TextAreaField, + TextField, + useFormApiRef, + useStackSwitchApi, +} from "@comet/admin"; +import { + type BlockState, + createFinalFormBlock, + DamImageBlock, + queryUpdatedAt, + resolveHasSaveConflict, + useContentScope, + useFormSaveConflict, +} from "@comet/cms-admin"; +import { InputAdornment } from "@mui/material"; +import { validatePositiveNumber } from "@src/common/validators/validatePositiveNumber"; +import { validateSkuFormat } from "@src/common/validators/validateSkuFormat"; +import { validateSlug } from "@src/common/validators/validateSlug"; +import { type GQLProductValidationErrorCode } from "@src/graphql.generated"; +import { ProductStatusSelectField } from "@src/products/components/productStatusSelectField/ProductStatusSelectField"; +import { ProductTypeSelectField } from "@src/products/components/productTypeSelectField/ProductTypeSelectField"; +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 { createProductMutation, productFormFragment, productQuery, updateProductMutation } from "./ProductForm.gql"; +import { + type GQLCreateProductMutation, + type GQLCreateProductMutationVariables, + type GQLProductFormDetailsFragment, + type GQLProductQuery, + type GQLProductQueryVariables, + type GQLUpdateProductMutation, + type GQLUpdateProductMutationVariables, +} from "./ProductForm.gql.generated"; + +const rootBlocks = { + mainImage: DamImageBlock, +}; + +type ProductFormDetailsFragment = Omit & { + publishedAt?: Date | null; + mainImage: BlockState; +}; + +type FormValues = ProductFormDetailsFragment; + +const submissionErrorMessages: Record = { + SLUG_ALREADY_EXISTS: , + SKU_ALREADY_EXISTS: , +}; + +interface FormProps { + id?: string; +} + +export function ProductForm({ 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( + productQuery, + id ? { variables: { id } } : { skip: true }, + ); + + const initialValues = useMemo>( + () => + data?.product + ? { + ...filterByFragment(productFormFragment, data.product), + publishedAt: data.product.publishedAt ? new Date(data.product.publishedAt) : undefined, + mainImage: data.product.mainImage + ? rootBlocks.mainImage.input2State(data.product.mainImage) + : rootBlocks.mainImage.defaultValues(), + } + : { + isPublished: false, + mainImage: rootBlocks.mainImage.defaultValues(), + }, + [data], + ); + + const saveConflict = useFormSaveConflict({ + checkConflict: async () => { + const updatedAt = await queryUpdatedAt(client, "product", id); + return resolveHasSaveConflict(data?.product.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 = { + ...formValues, + publishedAt: formValues.publishedAt ? formValues.publishedAt.toISOString() : null, + mainImage: rootBlocks.mainImage.state2Output(formValues.mainImage), + }; + + if (mode === "edit") { + if (!id) throw new Error(); + const { data: mutationResponse } = await client.mutate({ + mutation: updateProductMutation, + variables: { id, input: output }, + }); + + if (mutationResponse?.updateProduct.errors.length) { + return mutationResponse.updateProduct.errors.reduce( + (submissionErrors, error) => { + 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: createProductMutation, + variables: { scope, input: output }, + }); + + if (mutationResponse?.createProduct.errors.length) { + return mutationResponse.createProduct.errors.reduce( + (submissionErrors, error) => { + const errorMessage = submissionErrorMessages[error.code]; + if (error.field) { + submissionErrors[error.field] = errorMessage; + } else { + submissionErrors[FORM_ERROR] = errorMessage; + } + return submissionErrors; + }, + {} as Record, + ); + } + + const newId = mutationResponse?.createProduct.product?.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} +
}> + } + /> + } + validate={validateSlug} + helperText={ + + } + /> + } + /> +
+
}> + } + validate={validateSkuFormat} + helperText={ + + } + /> + } + validate={validatePositiveNumber} + endAdornment={} + /> + } + /> +
+
}> + } + /> + } + /> + } + variant="horizontal" + fullWidth + /> +
+
}> + } + variant="horizontal" + fullWidth + > + {createFinalFormBlock(rootBlocks.mainImage)} + +
+ + )} + + ); +} diff --git a/admin/src/products/components/productStatus/ProductStatus.tsx b/admin/src/products/components/productStatus/ProductStatus.tsx new file mode 100644 index 000000000..1facf2d89 --- /dev/null +++ b/admin/src/products/components/productStatus/ProductStatus.tsx @@ -0,0 +1,16 @@ +import { createTranslatableEnum } from "@src/common/components/enums/createTranslatableEnum/createTranslatableEnum"; +import { type GQLProductStatus } from "@src/graphql.generated"; +import { defineMessage } from "react-intl"; + +const { + messageDescriptorMap, + formattedMessageMap, + Component: ProductStatus, +} = createTranslatableEnum({ + Draft: defineMessage({ defaultMessage: "Draft", id: "products.productStatus.draft" }), + InReview: defineMessage({ defaultMessage: "In Review", id: "products.productStatus.inReview" }), + Published: defineMessage({ defaultMessage: "Published", id: "products.productStatus.published" }), + Archived: defineMessage({ defaultMessage: "Archived", id: "products.productStatus.archived" }), +}); + +export { ProductStatus, formattedMessageMap as productStatusFormattedMessageMap, messageDescriptorMap as productStatusMessageDescriptorMap }; diff --git a/admin/src/products/components/productStatusChip/ProductStatusChip.tsx b/admin/src/products/components/productStatusChip/ProductStatusChip.tsx new file mode 100644 index 000000000..a4df491cd --- /dev/null +++ b/admin/src/products/components/productStatusChip/ProductStatusChip.tsx @@ -0,0 +1,64 @@ +import { Chip } from "@mui/material"; +import { ChipIcon } from "@src/common/components/enums/chipIcon/ChipIcon"; +import { EnumChip, type EnumChipProps } from "@src/common/components/enums/enumChip/EnumChip"; +import { type GQLProductStatus } from "@src/graphql.generated"; +import { ProductStatus, productStatusFormattedMessageMap } from "@src/products/components/productStatus/ProductStatus"; +import { type FunctionComponent } from "react"; + +type ProductStatusChipProps = Pick, "loading" | "onSelectItem" | "value">; + +const productStatusSortOrder: GQLProductStatus[] = ["Draft", "InReview", "Published", "Archived"]; + +export const ProductStatusChip: FunctionComponent = ({ loading, onSelectItem, value }) => { + return ( + + chipMap={{ + Draft: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + InReview: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + Published: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + Archived: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + }} + formattedMessageMap={productStatusFormattedMessageMap} + loading={loading} + onSelectItem={onSelectItem} + sortOrder={productStatusSortOrder} + value={value} + /> + ); +}; diff --git a/admin/src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct.gql.ts b/admin/src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct.gql.ts new file mode 100644 index 000000000..860ae8c6e --- /dev/null +++ b/admin/src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct.gql.ts @@ -0,0 +1,21 @@ +import { gql } from "@apollo/client"; + +export const productStatusForProductQuery = gql` + query ProductStatusForProduct($id: ID!) { + product(id: $id) { + id + productStatus + } + } +`; + +export const updateProductProductStatusMutation = gql` + mutation UpdateProductProductStatus($id: ID!, $productStatus: ProductStatus!) { + updateProduct(id: $id, input: { productStatus: $productStatus }) { + product { + id + productStatus + } + } + } +`; diff --git a/admin/src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct.tsx b/admin/src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct.tsx new file mode 100644 index 000000000..342371777 --- /dev/null +++ b/admin/src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct.tsx @@ -0,0 +1,56 @@ +import { useMutation, useQuery } from "@apollo/client"; +import { InlineAlert, LocalErrorScopeApolloContext, Tooltip } from "@comet/admin"; +import { Error } from "@comet/admin-icons"; +import { Box } from "@mui/material"; +import { ProductStatusChip } from "@src/products/components/productStatusChip/ProductStatusChip"; +import { type FunctionComponent } from "react"; + +import { productStatusForProductQuery, updateProductProductStatusMutation } from "./ProductStatusChipEditableForProduct.gql"; +import { + type GQLProductStatusForProductQuery, + type GQLProductStatusForProductQueryVariables, + type GQLUpdateProductProductStatusMutation, + type GQLUpdateProductProductStatusMutationVariables, +} from "./ProductStatusChipEditableForProduct.gql.generated"; + +type ProductStatusChipEditableForProductProps = { + productId: string; +}; + +export const ProductStatusChipEditableForProduct: FunctionComponent = ({ productId }) => { + const { data, loading, error } = useQuery( + productStatusForProductQuery, + { + variables: { id: productId }, + context: LocalErrorScopeApolloContext, + }, + ); + const [updateMutation, { loading: updateLoading }] = useMutation< + GQLUpdateProductProductStatusMutation, + GQLUpdateProductProductStatusMutationVariables + >(updateProductProductStatusMutation); + + if (error) { + return ( + + + + } + variant="light" + > + + + ); + } + return data?.product.productStatus ? ( + { + updateMutation({ variables: { id: productId, productStatus } }); + }} + /> + ) : null; +}; diff --git a/admin/src/products/components/productStatusSelectField/ProductStatusSelectField.tsx b/admin/src/products/components/productStatusSelectField/ProductStatusSelectField.tsx new file mode 100644 index 000000000..d33333e4f --- /dev/null +++ b/admin/src/products/components/productStatusSelectField/ProductStatusSelectField.tsx @@ -0,0 +1,13 @@ +import { SelectField, type SelectFieldProps } from "@comet/admin"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions/recordToOptions"; +import { type GQLProductStatus } from "@src/graphql.generated"; +import { productStatusFormattedMessageMap } from "@src/products/components/productStatus/ProductStatus"; +import { type FunctionComponent } from "react"; + +type ProductStatusFormState = GQLProductStatus; + +type ProductStatusSelectFieldProps = Omit, "options">; + +export const ProductStatusSelectField: FunctionComponent = ({ name, ...restProps }) => { + return ; +}; diff --git a/admin/src/products/components/productToolbar/ProductToolbar.gql.ts b/admin/src/products/components/productToolbar/ProductToolbar.gql.ts new file mode 100644 index 000000000..716ff7d76 --- /dev/null +++ b/admin/src/products/components/productToolbar/ProductToolbar.gql.ts @@ -0,0 +1,11 @@ +import { gql } from "@apollo/client"; + +export const productToolbarQuery = gql` + query ProductToolbar($id: ID!) { + product(id: $id) { + id + name + sku + } + } +`; diff --git a/admin/src/products/components/productToolbar/ProductToolbar.tsx b/admin/src/products/components/productToolbar/ProductToolbar.tsx new file mode 100644 index 000000000..361f3281c --- /dev/null +++ b/admin/src/products/components/productToolbar/ProductToolbar.tsx @@ -0,0 +1,108 @@ +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 { productToolbarQuery } from "./ProductToolbar.gql"; +import { type GQLProductToolbarQuery, type GQLProductToolbarQueryVariables } from "./ProductToolbar.gql.generated"; + +interface ProductToolbarProps { + id?: string; + additionalActions?: ReactNode; +} + +export const ProductToolbar: FunctionComponent = ({ id, additionalActions }) => { + const theme = useTheme(); + + const { data, loading, error } = useQuery( + productToolbarQuery, + id != null + ? { + variables: { id }, + context: LocalErrorScopeApolloContext, + } + : { skip: true }, + ); + + if (loading) { + return ( + }> + + + + + ); + } + + const title = data?.product.name; + const supportText = data?.product.sku; + + return ( + + }> + + + {title ? ( + + + {title} + {supportText && ( + + {supportText} + + )} + + + ) : ( + + )} + + {error != null && ( + + + + + + + + + + } + > + + + + + + )} + + + + + {additionalActions} + + + + + ); +}; diff --git a/admin/src/products/components/productType/ProductType.tsx b/admin/src/products/components/productType/ProductType.tsx new file mode 100644 index 000000000..f8c0f577a --- /dev/null +++ b/admin/src/products/components/productType/ProductType.tsx @@ -0,0 +1,15 @@ +import { createTranslatableEnum } from "@src/common/components/enums/createTranslatableEnum/createTranslatableEnum"; +import { type GQLProductType } from "@src/graphql.generated"; +import { defineMessage } from "react-intl"; + +const { + messageDescriptorMap, + formattedMessageMap, + Component: ProductType, +} = createTranslatableEnum({ + Physical: defineMessage({ defaultMessage: "Physical", id: "products.productType.physical" }), + Digital: defineMessage({ defaultMessage: "Digital", id: "products.productType.digital" }), + Subscription: defineMessage({ defaultMessage: "Subscription", id: "products.productType.subscription" }), +}); + +export { ProductType, formattedMessageMap as productTypeFormattedMessageMap, messageDescriptorMap as productTypeMessageDescriptorMap }; diff --git a/admin/src/products/components/productTypeChip/ProductTypeChip.tsx b/admin/src/products/components/productTypeChip/ProductTypeChip.tsx new file mode 100644 index 000000000..8b74d7d6d --- /dev/null +++ b/admin/src/products/components/productTypeChip/ProductTypeChip.tsx @@ -0,0 +1,54 @@ +import { Chip } from "@mui/material"; +import { ChipIcon } from "@src/common/components/enums/chipIcon/ChipIcon"; +import { EnumChip, type EnumChipProps } from "@src/common/components/enums/enumChip/EnumChip"; +import { type GQLProductType } from "@src/graphql.generated"; +import { ProductType, productTypeFormattedMessageMap } from "@src/products/components/productType/ProductType"; +import { type FunctionComponent } from "react"; + +type ProductTypeChipProps = Pick, "loading" | "onSelectItem" | "value">; + +const productTypeSortOrder: GQLProductType[] = ["Physical", "Digital", "Subscription"]; + +export const ProductTypeChip: FunctionComponent = ({ loading, onSelectItem, value }) => { + return ( + + chipMap={{ + Physical: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + Digital: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + Subscription: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + }} + formattedMessageMap={productTypeFormattedMessageMap} + loading={loading} + onSelectItem={onSelectItem} + sortOrder={productTypeSortOrder} + value={value} + /> + ); +}; diff --git a/admin/src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct.gql.ts b/admin/src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct.gql.ts new file mode 100644 index 000000000..5cf4d4901 --- /dev/null +++ b/admin/src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct.gql.ts @@ -0,0 +1,21 @@ +import { gql } from "@apollo/client"; + +export const productTypeForProductQuery = gql` + query ProductTypeForProduct($id: ID!) { + product(id: $id) { + id + productType + } + } +`; + +export const updateProductProductTypeMutation = gql` + mutation UpdateProductProductType($id: ID!, $productType: ProductType!) { + updateProduct(id: $id, input: { productType: $productType }) { + product { + id + productType + } + } + } +`; diff --git a/admin/src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct.tsx b/admin/src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct.tsx new file mode 100644 index 000000000..7f09c4d00 --- /dev/null +++ b/admin/src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct.tsx @@ -0,0 +1,53 @@ +import { useMutation, useQuery } from "@apollo/client"; +import { InlineAlert, LocalErrorScopeApolloContext, Tooltip } from "@comet/admin"; +import { Error } from "@comet/admin-icons"; +import { Box } from "@mui/material"; +import { ProductTypeChip } from "@src/products/components/productTypeChip/ProductTypeChip"; +import { type FunctionComponent } from "react"; + +import { productTypeForProductQuery, updateProductProductTypeMutation } from "./ProductTypeChipEditableForProduct.gql"; +import { + type GQLProductTypeForProductQuery, + type GQLProductTypeForProductQueryVariables, + type GQLUpdateProductProductTypeMutation, + type GQLUpdateProductProductTypeMutationVariables, +} from "./ProductTypeChipEditableForProduct.gql.generated"; + +type ProductTypeChipEditableForProductProps = { + productId: string; +}; + +export const ProductTypeChipEditableForProduct: FunctionComponent = ({ productId }) => { + const { data, loading, error } = useQuery(productTypeForProductQuery, { + variables: { id: productId }, + context: LocalErrorScopeApolloContext, + }); + const [updateMutation, { loading: updateLoading }] = useMutation< + GQLUpdateProductProductTypeMutation, + GQLUpdateProductProductTypeMutationVariables + >(updateProductProductTypeMutation); + + if (error) { + return ( + + + + } + variant="light" + > + + + ); + } + return data?.product.productType ? ( + { + updateMutation({ variables: { id: productId, productType } }); + }} + /> + ) : null; +}; diff --git a/admin/src/products/components/productTypeSelectField/ProductTypeSelectField.tsx b/admin/src/products/components/productTypeSelectField/ProductTypeSelectField.tsx new file mode 100644 index 000000000..0ace19ced --- /dev/null +++ b/admin/src/products/components/productTypeSelectField/ProductTypeSelectField.tsx @@ -0,0 +1,13 @@ +import { SelectField, type SelectFieldProps } from "@comet/admin"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions/recordToOptions"; +import { type GQLProductType } from "@src/graphql.generated"; +import { productTypeFormattedMessageMap } from "@src/products/components/productType/ProductType"; +import { type FunctionComponent } from "react"; + +type ProductTypeFormState = GQLProductType; + +type ProductTypeSelectFieldProps = Omit, "options">; + +export const ProductTypeSelectField: FunctionComponent = ({ name, ...restProps }) => { + return ; +}; diff --git a/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts b/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts new file mode 100644 index 000000000..2fc5feca8 --- /dev/null +++ b/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts @@ -0,0 +1,33 @@ +import { gql } from "@apollo/client"; + +const productsFragment = gql` + fragment ProductsGridItem on Product { + id + mainImage + name + sku + productType + price + productStatus + publishedAt + isPublished + } +`; + +export const productsQuery = gql` + query ProductsGrid($scope: ProductScopeInput!, $offset: Int!, $limit: Int!, $sort: [ProductSort!]!, $search: String, $filter: ProductFilter) { + products(scope: $scope, offset: $offset, limit: $limit, sort: $sort, search: $search, filter: $filter) { + nodes { + ...ProductsGridItem + } + totalCount + } + } + ${productsFragment} +`; + +export const deleteProductMutation = gql` + mutation DeleteProduct($id: ID!) { + deleteProduct(id: $id) + } +`; diff --git a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx new file mode 100644 index 000000000..7df68227c --- /dev/null +++ b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx @@ -0,0 +1,228 @@ +import { useApolloClient, useQuery } from "@apollo/client"; +import { + CrudContextMenu, + dataGridDateTimeColumn, + type GridColDef, + muiGridFilterToGql, + muiGridSortToGql, + StackLink, + Tooltip, + useBufferedRowCount, + useDataGridExcelExport, + useDataGridRemote, + usePersistentColumnState, + useStackSwitchApi, +} from "@comet/admin"; +import { Edit as EditIcon, Info as InfoIcon } from "@comet/admin-icons"; +import { useContentScope } from "@comet/cms-admin"; +import { Box, IconButton } from "@mui/material"; +import { DataGridPro, type DataGridProProps, GridColumnHeaderTitle, type GridSlotsComponent } from "@mui/x-data-grid-pro"; +import { messageDescriptorMapToValueOptions } from "@src/common/components/enums/messageDescriptorMapToValueOptions/messageDescriptorMapToValueOptions"; +import { productStatusMessageDescriptorMap } from "@src/products/components/productStatus/ProductStatus"; +import { ProductStatusChipEditableForProduct } from "@src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct"; +import { productTypeMessageDescriptorMap } from "@src/products/components/productType/ProductType"; +import { ProductTypeChipEditableForProduct } from "@src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct"; +import { useMemo } from "react"; +import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; + +import { deleteProductMutation, productsQuery } from "./ProductsGrid.gql"; +import { + type GQLDeleteProductMutation, + type GQLDeleteProductMutationVariables, + type GQLProductsGridItemFragment, + type GQLProductsGridQuery, + type GQLProductsGridQueryVariables, +} from "./ProductsGrid.gql.generated"; +import { ProductsGridToolbar, type ProductsGridToolbarProps } from "./toolbar/ProductsGridToolbar"; + +export function ProductsGrid() { + const client = useApolloClient(); + const intl = useIntl(); + const { scope } = useContentScope(); + const dataGridProps = { + ...useDataGridRemote({ + queryParamsPrefix: "products", + }), + ...usePersistentColumnState("ProductsGrid"), + }; + const stackSwitchApi = useStackSwitchApi(); + + const handleRowClick: DataGridProProps["onRowClick"] = (params) => { + stackSwitchApi.activatePage("edit", params.row.id); + }; + + const columns: GridColDef[] = useMemo( + () => [ + { + field: "mainImage", + headerName: intl.formatMessage({ id: "productsGrid.mainImage", defaultMessage: "Image" }), + sortable: false, + filterable: false, + disableExport: true, + width: 80, + renderCell: ({ row }) => { + const damFile = row.mainImage?.attachedBlocks?.[0]?.props?.damFile; + if (!damFile?.fileUrl) return null; + return ( + + + + ); + }, + }, + { + field: "name", + headerName: intl.formatMessage({ id: "productsGrid.name", defaultMessage: "Name" }), + flex: 1, + minWidth: 200, + }, + { + field: "sku", + headerName: intl.formatMessage({ id: "productsGrid.sku", defaultMessage: "SKU" }), + width: 150, + }, + { + field: "productType", + headerName: intl.formatMessage({ id: "productsGrid.productType", defaultMessage: "Type" }), + type: "singleSelect", + valueOptions: messageDescriptorMapToValueOptions(productTypeMessageDescriptorMap, intl), + width: 160, + renderCell: ({ row }) => ( + e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> + + + ), + }, + { + field: "price", + renderHeader: () => ( + <> + + }> + + + + ), + headerName: intl.formatMessage({ id: "productsGrid.price", defaultMessage: "Price" }), + type: "number", + renderCell: ({ value }) => { + return typeof value === "number" ? ( + + ) : ( + "" + ); + }, + width: 150, + }, + { + field: "productStatus", + headerName: intl.formatMessage({ id: "productsGrid.productStatus", defaultMessage: "Status" }), + type: "singleSelect", + valueOptions: messageDescriptorMapToValueOptions(productStatusMessageDescriptorMap, intl), + width: 160, + renderCell: ({ row }) => ( + e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> + + + ), + }, + { + ...dataGridDateTimeColumn, + field: "publishedAt", + headerName: intl.formatMessage({ id: "productsGrid.publishedAt", defaultMessage: "Published At" }), + width: 170, + }, + { + field: "isPublished", + headerName: intl.formatMessage({ id: "productsGrid.isPublished", defaultMessage: "Published" }), + type: "boolean", + width: 100, + }, + { + field: "actions", + headerName: "", + sortable: false, + filterable: false, + type: "actions", + align: "right", + pinned: "right", + width: 84, + disableExport: true, + renderCell: (params) => { + return ( + <> + + + + { + await client.mutate({ + mutation: deleteProductMutation, + variables: { id: params.row.id }, + }); + }} + refetchQueries={[productsQuery]} + /> + + ); + }, + }, + ], + [intl, client], + ); + + const { filter: gqlFilter, search: gqlSearch } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const { data, loading, error } = useQuery(productsQuery, { + variables: { + scope, + filter: gqlFilter, + search: gqlSearch, + sort: muiGridSortToGql(dataGridProps.sortModel, columns) ?? [], + offset: dataGridProps.paginationModel.page * dataGridProps.paginationModel.pageSize, + limit: dataGridProps.paginationModel.pageSize, + }, + }); + const rowCount = useBufferedRowCount(data?.products.totalCount); + if (error) throw error; + const rows = data?.products.nodes ?? []; + + const exportApi = useDataGridExcelExport< + GQLProductsGridQuery["products"]["nodes"][0], + GQLProductsGridQuery, + Omit + >({ + columns, + variables: { + scope, + ...muiGridFilterToGql(columns, dataGridProps.filterModel), + sort: muiGridSortToGql(dataGridProps.sortModel, columns) ?? [], + }, + query: productsQuery, + resolveQueryNodes: (data) => data.products.nodes, + totalCount: data?.products.totalCount ?? 0, + exportOptions: { + fileName: "Products", + }, + }); + + return ( + + ); +} diff --git a/admin/src/products/components/productsDataGrid/toolbar/ProductsGridToolbar.tsx b/admin/src/products/components/productsDataGrid/toolbar/ProductsGridToolbar.tsx new file mode 100644 index 000000000..a4f743b64 --- /dev/null +++ b/admin/src/products/components/productsDataGrid/toolbar/ProductsGridToolbar.tsx @@ -0,0 +1,37 @@ +import { CrudMoreActionsMenu, DataGridToolbar, type ExportApi, FillSpace, GridFilterButton, messages } from "@comet/admin"; +import { Excel as ExcelIcon } from "@comet/admin-icons"; +import { CircularProgress } from "@mui/material"; +import { type GridToolbarProps, GridToolbarQuickFilter } from "@mui/x-data-grid-pro"; +import { type ReactNode } from "react"; +import { FormattedMessage } from "react-intl"; + +export interface ProductsGridToolbarProps extends GridToolbarProps { + toolbarAction?: ReactNode; + exportApi: ExportApi; +} + +export function ProductsGridToolbar({ toolbarAction, exportApi }: ProductsGridToolbarProps) { + return ( + + + + + , + icon: exportApi.loading ? : , + onClick: () => exportApi.exportGrid(), + disabled: exportApi.loading, + }, + ]} + /> + {toolbarAction} + + ); +} diff --git a/api/schema.gql b/api/schema.gql index b125ace6f..1207d12ee 100644 --- a/api/schema.gql +++ b/api/schema.gql @@ -26,6 +26,11 @@ input CreateDamFolderInput { parentId: ID } +type CreateProductPayload { + errors: [ProductValidationError!]! + product: Product +} + type CurrentUser { accountUrl: String allowedContentScopes: [ContentScopeWithLabel!]! @@ -106,6 +111,12 @@ type DamFolder { updatedAt: DateTime! } +"""DamImage root block data""" +scalar DamImageBlockData @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +"""DamImage root block input""" +scalar DamImageBlockInput @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + union DamItem = DamFile | DamFolder input DamItemFilterInput { @@ -266,6 +277,12 @@ input FooterScopeInput { language: String! } +input IdFilter { + equal: ID + isAnyOf: [ID!] + notEqual: ID +} + type ImageCropArea { focalPoint: FocalPoint! height: Float @@ -341,12 +358,14 @@ type Mutation { createDamFolder(input: CreateDamFolderInput!, scope: DamScopeInput! = {}): DamFolder! createDamMediaAlternative(alternative: ID!, for: ID!, input: DamMediaAlternativeInput!): DamMediaAlternative! createPageTreeNode(category: String!, input: PageTreeNodeCreateInput!, scope: PageTreeNodeScopeInput!): PageTreeNode! + createProduct(input: ProductInput!, scope: ProductScopeInput!): CreateProductPayload! createRedirect(input: RedirectInput!, scope: RedirectScopeInput!): Redirect! currentUserSignOut: String! deleteDamFile(id: ID!): Boolean! deleteDamFolder(id: ID!): Boolean! deleteDamMediaAlternative(id: ID!): Boolean! deletePageTreeNode(id: ID!): Boolean! + deleteProduct(id: ID!): Boolean! deleteRedirect(id: ID!): Boolean! importDamFileByDownload(input: UpdateDamFileInput!, scope: DamScopeInput! = {}, url: String!): DamFile! moveDamFiles(fileIds: [ID!]!, targetFolderId: ID): [DamFile!]! @@ -365,6 +384,7 @@ type Mutation { updatePageTreeNodeCategory(category: String!, id: ID!): PageTreeNode! updatePageTreeNodeSlug(id: ID!, slug: String!): PageTreeNode! updatePageTreeNodeVisibility(id: ID!, input: PageTreeNodeUpdateVisibilityInput!): PageTreeNode! + updateProduct(id: ID!, input: ProductUpdateInput!): UpdateProductPayload! updateRedirect(id: ID!, input: RedirectInput!, lastUpdatedAt: DateTime): Redirect! updateRedirectActiveness(id: ID!, input: RedirectUpdateActivenessInput!): Redirect! userPermissionsCreatePermission(input: UserPermissionInput!, userId: String!): UserPermission! @@ -374,6 +394,18 @@ type Mutation { userPermissionsUpdatePermission(id: String!, input: UserPermissionInput!): UserPermission! } +input NumberFilter { + equal: Float + greaterThan: Float + greaterThanEqual: Float + isAnyOf: [Float!] + isEmpty: Boolean + isNotEmpty: Boolean + lowerThan: Float + lowerThanEqual: Float + notEqual: Float +} + type Page implements DocumentInterface { content: PageContentBlockData! createdAt: DateTime! @@ -501,6 +533,11 @@ type PaginatedPageTreeNodes { totalCount: Int! } +type PaginatedProducts { + nodes: [Product!]! + totalCount: Int! +} + type PaginatedRedirects { nodes: [Redirect!]! totalCount: Int! @@ -521,6 +558,7 @@ enum Permission { impersonation pageTree prelogin + products sitePreview translation userPermissions @@ -533,6 +571,125 @@ input PermissionFilter { notEqual: Permission } +type Product { + createdAt: DateTime! + description: String + domain: String! + id: ID! + isPublished: Boolean! + language: String! + mainImage: DamImageBlockData + name: String! + price: Float! + productStatus: ProductStatus! + productType: ProductType! + publishedAt: DateTime + sku: String! + slug: String! + updatedAt: DateTime! +} + +input ProductFilter { + and: [ProductFilter!] + createdAt: DateTimeFilter + id: IdFilter + isPublished: BooleanFilter + name: StringFilter + or: [ProductFilter!] + price: NumberFilter + productStatus: ProductStatusEnumFilter + productType: ProductTypeEnumFilter + publishedAt: DateTimeFilter + sku: StringFilter + slug: StringFilter + updatedAt: DateTimeFilter +} + +input ProductInput { + description: String + isPublished: Boolean! = false + mainImage: DamImageBlockInput + name: String! + price: Float! + productStatus: ProductStatus! = Draft + productType: ProductType! + publishedAt: DateTime + sku: String! + slug: String! +} + +input ProductScopeInput { + domain: String! + language: String! +} + +input ProductSort { + direction: SortDirection! = ASC + field: ProductSortField! +} + +enum ProductSortField { + createdAt + id + isPublished + name + price + productStatus + productType + publishedAt + sku + slug + updatedAt +} + +enum ProductStatus { + Archived + Draft + InReview + Published +} + +input ProductStatusEnumFilter { + equal: ProductStatus + isAnyOf: [ProductStatus!] + notEqual: ProductStatus +} + +enum ProductType { + Digital + Physical + Subscription +} + +input ProductTypeEnumFilter { + equal: ProductType + isAnyOf: [ProductType!] + notEqual: ProductType +} + +input ProductUpdateInput { + description: String + isPublished: Boolean + mainImage: DamImageBlockInput + name: String + price: Float + productStatus: ProductStatus + productType: ProductType + publishedAt: DateTime + sku: String + slug: String +} + +type ProductValidationError { + code: ProductValidationErrorCode! + field: String +} + +enum ProductValidationErrorCode { + SKU_ALREADY_EXISTS + SLUG_ALREADY_EXISTS +} + type Query { blockPreviewJwt(includeInvisible: Boolean!, scope: JSONObject!, url: String!): String! currentUser: CurrentUser! @@ -559,6 +716,9 @@ type Query { pageTreeNodeSlugAvailable(parentId: ID, scope: PageTreeNodeScopeInput!, slug: String!): SlugAvailability! paginatedPageTreeNodes(category: String, documentType: String, limit: Int! = 25, offset: Int! = 0, scope: PageTreeNodeScopeInput!, sort: [PageTreeNodeSort!]): PaginatedPageTreeNodes! 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 + 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 redirectSourceAvailable(scope: RedirectScopeInput!, source: String!): Boolean! @@ -703,6 +863,11 @@ input UpdateImageFileInput { cropArea: ImageCropAreaInput } +type UpdateProductPayload { + errors: [ProductValidationError!]! + product: Product +} + input UserContentScopesInput { contentScopes: [JSONObject!]! = [] } diff --git a/api/src/app.module.ts b/api/src/app.module.ts index de070c9a8..474a6d597 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 { ProductsModule } from "./products/products.module"; import { StatusModule } from "./status/status.module"; @Module({}) @@ -134,6 +135,7 @@ export class AppModule { MenusModule, DependenciesModule, FootersModule, + ProductsModule, WarningsModule, ...(!config.debug ? [ diff --git a/api/src/auth/app-permission.enum.ts b/api/src/auth/app-permission.enum.ts index 6b41ed78f..314073697 100644 --- a/api/src/auth/app-permission.enum.ts +++ b/api/src/auth/app-permission.enum.ts @@ -1 +1,3 @@ -export enum AppPermission {} +export enum AppPermission { + products = "products", +} diff --git a/api/src/db/migrations/Migration20260313144308.ts b/api/src/db/migrations/Migration20260313144308.ts new file mode 100644 index 000000000..c4340e35f --- /dev/null +++ b/api/src/db/migrations/Migration20260313144308.ts @@ -0,0 +1,12 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20260313144308 extends Migration { + override async up(): Promise { + this.addSql(`create table if not exists "Product" ("id" uuid not null, "name" text not null, "slug" text not null, "description" text null, "price" real not null, "sku" text not null, "publishedAt" timestamptz null, "isPublished" boolean not null default false, "productStatus" text check ("productStatus" in ('Draft', 'InReview', 'Published', 'Archived')) not null default 'Draft', "productType" text check ("productType" in ('Physical', 'Digital', 'Subscription')) not null, "mainImage" json null, "domain" text not null, "language" text not null, "createdAt" timestamptz not null, "updatedAt" timestamptz not null, constraint "Product_pkey" primary key ("id"));`); + this.addSql(`alter table "Product" alter column "price" type real using ("price"::real);`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "Product" cascade;`); + } +} diff --git a/api/src/products/dto/paginated-products.ts b/api/src/products/dto/paginated-products.ts new file mode 100644 index 000000000..a29d0517d --- /dev/null +++ b/api/src/products/dto/paginated-products.ts @@ -0,0 +1,6 @@ +import { PaginatedResponseFactory } from "@comet/cms-api"; +import { ObjectType } from "@nestjs/graphql"; +import { Product } from "@src/products/entities/product.entity"; + +@ObjectType() +export class PaginatedProducts extends PaginatedResponseFactory.create(Product) {} diff --git a/api/src/products/dto/product-scope.input.ts b/api/src/products/dto/product-scope.input.ts new file mode 100644 index 000000000..9b451405e --- /dev/null +++ b/api/src/products/dto/product-scope.input.ts @@ -0,0 +1,14 @@ +import { Field, InputType, ObjectType } from "@nestjs/graphql"; +import { IsString } from "class-validator"; + +@ObjectType() +@InputType("ProductScopeInput") +export class ProductScope { + @Field() + @IsString() + domain: string; + + @Field() + @IsString() + language: string; +} diff --git a/api/src/products/dto/product.filter.ts b/api/src/products/dto/product.filter.ts new file mode 100644 index 000000000..f4a7b4189 --- /dev/null +++ b/api/src/products/dto/product.filter.ts @@ -0,0 +1,92 @@ +import { BooleanFilter, createEnumFilter, DateTimeFilter, IdFilter, 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"; +import { IsOptional, ValidateNested } from "class-validator"; + +@InputType("ProductStatusEnumFilter") +class ProductStatusFilter extends createEnumFilter(ProductStatus) {} + +@InputType("ProductTypeEnumFilter") +class ProductTypeFilter extends createEnumFilter(ProductType) {} + +@InputType() +export class ProductFilter { + @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) + price?: NumberFilter; + + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => StringFilter) + sku?: StringFilter; + + @Field(() => DateTimeFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => DateTimeFilter) + publishedAt?: DateTimeFilter; + + @Field(() => BooleanFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => BooleanFilter) + isPublished?: BooleanFilter; + + @Field(() => ProductStatusFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => ProductStatusFilter) + productStatus?: typeof ProductStatusFilter; + + @Field(() => ProductTypeFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => ProductTypeFilter) + productType?: typeof ProductTypeFilter; + + @Field(() => DateTimeFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => DateTimeFilter) + createdAt?: DateTimeFilter; + + @Field(() => DateTimeFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => DateTimeFilter) + updatedAt?: DateTimeFilter; + + @Field(() => [ProductFilter], { nullable: true }) + @Type(() => ProductFilter) + @ValidateNested({ each: true }) + @IsOptional() + and?: ProductFilter[]; + + @Field(() => [ProductFilter], { nullable: true }) + @Type(() => ProductFilter) + @ValidateNested({ each: true }) + @IsOptional() + or?: ProductFilter[]; +} diff --git a/api/src/products/dto/product.input.ts b/api/src/products/dto/product.input.ts new file mode 100644 index 000000000..da2b5b32d --- /dev/null +++ b/api/src/products/dto/product.input.ts @@ -0,0 +1,62 @@ +import { BlockInputInterface, DamImageBlock, isBlockInputInterface, IsSlug, PartialType, RootBlockInputScalar } from "@comet/cms-api"; +import { Field, Float, 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"; + +@InputType() +export class ProductInput { + @IsNotEmpty() + @IsString() + @Field() + name: string; + + @IsNotEmpty() + @IsSlug() + @Field() + slug: string; + + @IsOptional() + @IsString() + @Field({ nullable: true }) + description?: string; + + @IsNotEmpty() + @IsNumber() + @Field(() => Float) + price: number; + + @IsNotEmpty() + @IsString() + @Field() + sku: string; + + @IsOptional() + @IsDate() + @Field({ nullable: true }) + publishedAt?: Date; + + @IsNotEmpty() + @IsBoolean() + @Field({ defaultValue: false }) + isPublished: boolean; + + @IsNotEmpty() + @IsEnum(ProductStatus) + @Field(() => ProductStatus, { defaultValue: ProductStatus.Draft }) + productStatus: ProductStatus; + + @IsNotEmpty() + @IsEnum(ProductType) + @Field(() => ProductType) + productType: ProductType; + + @IsOptional() + @Field(() => RootBlockInputScalar(DamImageBlock), { nullable: true }) + @Transform(({ value }) => (isBlockInputInterface(value) ? value : DamImageBlock.blockInputFactory(value)), { toClassOnly: true }) + @ValidateNested() + mainImage?: BlockInputInterface; +} + +@InputType() +export class ProductUpdateInput extends PartialType(ProductInput) {} diff --git a/api/src/products/dto/product.sort.ts b/api/src/products/dto/product.sort.ts new file mode 100644 index 000000000..6fe414b61 --- /dev/null +++ b/api/src/products/dto/product.sort.ts @@ -0,0 +1,30 @@ +import { SortDirection } from "@comet/cms-api"; +import { Field, InputType, registerEnumType } from "@nestjs/graphql"; +import { IsEnum } from "class-validator"; + +export enum ProductSortField { + name = "name", + slug = "slug", + price = "price", + sku = "sku", + publishedAt = "publishedAt", + isPublished = "isPublished", + productStatus = "productStatus", + productType = "productType", + createdAt = "createdAt", + updatedAt = "updatedAt", + id = "id", +} + +registerEnumType(ProductSortField, { name: "ProductSortField" }); + +@InputType() +export class ProductSort { + @Field(() => ProductSortField) + @IsEnum(ProductSortField) + field: ProductSortField; + + @Field(() => SortDirection, { defaultValue: SortDirection.ASC }) + @IsEnum(SortDirection) + direction: SortDirection = SortDirection.ASC; +} diff --git a/api/src/products/dto/products.args.ts b/api/src/products/dto/products.args.ts new file mode 100644 index 000000000..2e1b44a72 --- /dev/null +++ b/api/src/products/dto/products.args.ts @@ -0,0 +1,31 @@ +import { OffsetBasedPaginationArgs, SortDirection } from "@comet/cms-api"; +import { ArgsType, Field } from "@nestjs/graphql"; +import { ProductFilter } from "@src/products/dto/product.filter"; +import { ProductSort, ProductSortField } from "@src/products/dto/product.sort"; +import { ProductScope } from "@src/products/dto/product-scope.input"; +import { Type } from "class-transformer"; +import { IsOptional, IsString, ValidateNested } from "class-validator"; + +@ArgsType() +export class ProductsArgs extends OffsetBasedPaginationArgs { + @Field(() => ProductScope) + @ValidateNested() + @Type(() => ProductScope) + scope: ProductScope; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + search?: string; + + @Field(() => ProductFilter, { nullable: true }) + @ValidateNested() + @Type(() => ProductFilter) + @IsOptional() + filter?: ProductFilter; + + @Field(() => [ProductSort], { defaultValue: [{ field: ProductSortField.createdAt, direction: SortDirection.ASC }] }) + @ValidateNested({ each: true }) + @Type(() => ProductSort) + sort: ProductSort[]; +} diff --git a/api/src/products/entities/product.entity.ts b/api/src/products/entities/product.entity.ts new file mode 100644 index 000000000..702fc450b --- /dev/null +++ b/api/src/products/entities/product.entity.ts @@ -0,0 +1,93 @@ +import { BlockDataInterface, DamImageBlock, RootBlock, RootBlockDataScalar, RootBlockEntity, RootBlockType, ScopedEntity } from "@comet/cms-api"; +import { BaseEntity, Entity, Enum, OptionalProps, PrimaryKey, Property } from "@mikro-orm/postgresql"; +import { Field, Float, ID, ObjectType, registerEnumType } from "@nestjs/graphql"; +import { v4 as uuid } from "uuid"; + +export enum ProductStatus { + Draft = "Draft", + InReview = "InReview", + Published = "Published", + Archived = "Archived", +} + +registerEnumType(ProductStatus, { name: "ProductStatus" }); + +export enum ProductType { + Physical = "Physical", + Digital = "Digital", + Subscription = "Subscription", +} + +registerEnumType(ProductType, { name: "ProductType" }); + +@Entity() +@ObjectType() +@RootBlockEntity() +@ScopedEntity((product) => ({ + domain: product.domain, + language: product.language, +})) +export class Product extends BaseEntity { + [OptionalProps]?: "createdAt" | "updatedAt" | "isPublished" | "productStatus"; + + @PrimaryKey({ type: "uuid" }) + @Field(() => ID) + id: string = uuid(); + + @Property({ type: "text" }) + @Field() + name: string; + + @Property({ type: "text" }) + @Field() + slug: string; + + @Property({ type: "text", nullable: true }) + @Field({ nullable: true }) + description?: string; + + @Property({ type: "float" }) + @Field(() => Float) + price: number; + + @Property({ type: "text" }) + @Field() + sku: string; + + @Property({ type: "timestamp with time zone", nullable: true }) + @Field({ nullable: true }) + publishedAt?: Date; + + @Property({ type: "boolean" }) + @Field() + isPublished: boolean = false; + + @Enum({ items: () => ProductStatus }) + @Field(() => ProductStatus) + productStatus: ProductStatus = ProductStatus.Draft; + + @Enum({ items: () => ProductType }) + @Field(() => ProductType) + productType: ProductType; + + @RootBlock(DamImageBlock) + @Property({ type: new RootBlockType(DamImageBlock), nullable: true }) + @Field(() => RootBlockDataScalar(DamImageBlock), { nullable: true }) + mainImage?: BlockDataInterface; + + @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/products/product.resolver.ts b/api/src/products/product.resolver.ts new file mode 100644 index 000000000..14cb598fe --- /dev/null +++ b/api/src/products/product.resolver.ts @@ -0,0 +1,91 @@ +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 { 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 { PaginatedProducts } from "./dto/paginated-products"; +import { ProductsService, ProductValidationError } from "./products.service"; + +@ObjectType() +class CreateProductPayload { + @Field(() => Product, { nullable: true }) + product?: Product; + + @Field(() => [ProductValidationError], { nullable: false }) + errors: ProductValidationError[]; +} + +@ObjectType() +class UpdateProductPayload { + @Field(() => Product, { nullable: true }) + product?: Product; + + @Field(() => [ProductValidationError], { nullable: false }) + errors: ProductValidationError[]; +} + +@Resolver(() => Product) +@RequiredPermission(["products"]) +export class ProductResolver { + constructor(private readonly productsService: ProductsService) {} + + @Query(() => Product) + @AffectedEntity(Product) + async product( + @Args("id", { type: () => ID }) + id: string, + ): Promise { + return this.productsService.findOneById(id); + } + + @Query(() => PaginatedProducts) + async products( + @Args() + args: ProductsArgs, + ): Promise { + return this.productsService.findAll(args); + } + + @Query(() => Product, { nullable: true }) + async productBySlug(@Args("scope", { type: () => ProductScope }) scope: ProductScope, @Args("slug") slug: string): Promise { + return this.productsService.findBySlug(scope, slug); + } + + @Mutation(() => CreateProductPayload) + async createProduct( + @Args("scope", { type: () => ProductScope }) scope: ProductScope, + @Args("input", { type: () => ProductInput }) input: ProductInput, + @GetCurrentUser() user: CurrentUser, + ): Promise { + return this.productsService.create(scope, input, user); + } + + @Mutation(() => UpdateProductPayload) + @AffectedEntity(Product) + async updateProduct( + @Args("id", { type: () => ID }) id: string, + @Args("input", { type: () => ProductUpdateInput }) input: ProductUpdateInput, + @GetCurrentUser() user: CurrentUser, + ): Promise { + return this.productsService.update(id, input, user); + } + + @Mutation(() => Boolean) + @AffectedEntity(Product) + async deleteProduct( + @Args("id", { type: () => ID }) + id: string, + ): Promise { + return this.productsService.delete(id); + } + + @ResolveField(() => RootBlockDataScalar(DamImageBlock), { nullable: true }) + async mainImage(@Parent() product: Product): Promise { + if (!product.mainImage) { + return undefined; + } + return this.productsService.transformToPlain(product.mainImage); + } +} diff --git a/api/src/products/products.module.ts b/api/src/products/products.module.ts new file mode 100644 index 000000000..8f6aa266f --- /dev/null +++ b/api/src/products/products.module.ts @@ -0,0 +1,12 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { Module } from "@nestjs/common"; + +import { Product } from "./entities/product.entity"; +import { ProductResolver } from "./product.resolver"; +import { ProductsService } from "./products.service"; + +@Module({ + imports: [MikroOrmModule.forFeature([Product])], + providers: [ProductsService, ProductResolver], +}) +export class ProductsModule {} diff --git a/api/src/products/products.service.ts b/api/src/products/products.service.ts new file mode 100644 index 000000000..40a4827fd --- /dev/null +++ b/api/src/products/products.service.ts @@ -0,0 +1,150 @@ +import { BlockDataInterface, BlocksTransformerService, CurrentUser, gqlArgsToMikroOrmQuery, gqlSortToMikroOrmOrderBy } from "@comet/cms-api"; +import { EntityManager, FindOptions } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; +import { Field, ObjectType, registerEnumType } from "@nestjs/graphql"; +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 { PaginatedProducts } from "./dto/paginated-products"; + +enum ProductValidationErrorCode { + SLUG_ALREADY_EXISTS = "SLUG_ALREADY_EXISTS", + SKU_ALREADY_EXISTS = "SKU_ALREADY_EXISTS", +} + +registerEnumType(ProductValidationErrorCode, { name: "ProductValidationErrorCode" }); + +@ObjectType() +export class ProductValidationError { + @Field({ nullable: true }) + field?: string; + + @Field(() => ProductValidationErrorCode) + code: ProductValidationErrorCode; +} + +@Injectable() +export class ProductsService { + constructor( + private readonly entityManager: EntityManager, + private readonly blocksTransformer: BlocksTransformerService, + ) {} + + async findOneById(id: string): Promise { + return this.entityManager.findOneOrFail(Product, id); + } + + async findAll({ scope, search, filter, sort, offset, limit }: ProductsArgs): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(Product)); + Object.assign(where, scope); + const options: FindOptions = { offset, limit }; + if (sort) { + options.orderBy = gqlSortToMikroOrmOrderBy(sort); + } + const [entities, totalCount] = await this.entityManager.findAndCount(Product, where, options); + return new PaginatedProducts(entities, totalCount); + } + + async findBySlug(scope: ProductScope, slug: string): Promise { + const product = await this.entityManager.findOne(Product, { slug, ...scope }); + return product ?? null; + } + + async create(scope: ProductScope, input: ProductInput, user: CurrentUser): Promise<{ product?: Product; errors: ProductValidationError[] }> { + const errors = await this.validateCreateInput(input, { currentUser: user, scope }); + if (errors.length > 0) { + return { errors }; + } + + const { mainImage: mainImageInput, ...assignInput } = input; + const product = this.entityManager.create(Product, { + ...assignInput, + ...scope, + ...(mainImageInput ? { mainImage: mainImageInput.transformToBlockData() } : {}), + }); + await this.entityManager.flush(); + return { product, errors: [] }; + } + + async update(id: string, input: ProductUpdateInput, user: CurrentUser): Promise<{ product?: Product; errors: ProductValidationError[] }> { + const product = await this.entityManager.findOneOrFail(Product, id); + + const errors = await this.validateUpdateInput(input, { currentUser: user, entity: product }); + if (errors.length > 0) { + return { errors }; + } + + const { mainImage: mainImageInput, ...assignInput } = input; + product.assign({ ...assignInput }); + if (mainImageInput) { + product.mainImage = mainImageInput.transformToBlockData(); + } + await this.entityManager.flush(); + return { product, errors: [] }; + } + + async delete(id: string): Promise { + const product = await this.entityManager.findOneOrFail(Product, id); + this.entityManager.remove(product); + await this.entityManager.flush(); + return true; + } + + async transformToPlain(blockData: BlockDataInterface): Promise { + return this.blocksTransformer.transformToPlain(blockData); + } + + private async validateCreateInput( + input: ProductInput, + context: { currentUser: CurrentUser; scope: ProductScope }, + ): Promise { + const errors: ProductValidationError[] = []; + + const existingSlug = await this.entityManager.findOne(Product, { slug: input.slug, ...context.scope }); + if (existingSlug) { + errors.push({ field: "slug", code: ProductValidationErrorCode.SLUG_ALREADY_EXISTS }); + } + + const existingSku = await this.entityManager.findOne(Product, { sku: input.sku, ...context.scope }); + if (existingSku) { + errors.push({ field: "sku", code: ProductValidationErrorCode.SKU_ALREADY_EXISTS }); + } + + return errors; + } + + private async validateUpdateInput( + input: ProductUpdateInput, + context: { currentUser: CurrentUser; entity: Product }, + ): Promise { + const errors: ProductValidationError[] = []; + + if (input.slug !== undefined) { + const existingSlug = await this.entityManager.findOne(Product, { + slug: input.slug, + domain: context.entity.domain, + language: context.entity.language, + id: { $ne: context.entity.id }, + }); + if (existingSlug) { + errors.push({ field: "slug", code: ProductValidationErrorCode.SLUG_ALREADY_EXISTS }); + } + } + + if (input.sku !== undefined) { + const existingSku = await this.entityManager.findOne(Product, { + sku: input.sku, + domain: context.entity.domain, + language: context.entity.language, + id: { $ne: context.entity.id }, + }); + if (existingSku) { + errors.push({ field: "sku", code: ProductValidationErrorCode.SKU_ALREADY_EXISTS }); + } + } + + return errors; + } +} diff --git a/specs/product/01-product.md b/specs/product/01-product.md new file mode 100644 index 000000000..0c5113437 --- /dev/null +++ b/specs/product/01-product.md @@ -0,0 +1,74 @@ +--- +title: "01 - Product" +--- + +# Product + +The Product entity represents a product in the catalog. Products are scoped by domain and language. + +## Fields + +- name: string, required +- slug: string, required, unique per scope +- description: string, optional, multiline +- price: number, required, decimal +- sku: string, required, unique per scope +- publishedAt: date, optional +- isPublished: boolean, default false +- mainImage: DAM image, optional + +## Enums + +- productStatus: Draft, InReview, Published, Archived +- productType: Physical, Digital, Subscription + +## Requirements + +- The entity is scoped (domain + language). +- Slug must be validated for uniqueness within the scope. +- Price must be a positive number. +- SKU must match the format `[A-Z]{2,4}-[0-9]{4,8}` (e.g., "PROD-12345"). + +## API Validation + +- Slug uniqueness must be validated server-side (mutation validation). Return a field-level error `SLUG_ALREADY_EXISTS` if a product with the same slug exists in the same scope. +- SKU uniqueness must be validated server-side. Return a field-level error `SKU_ALREADY_EXISTS`. + +## DataGrid + +Columns: mainImage as thumbnail, name, sku, productType as editable chip, price, productStatus as editable chip, publishedAt, isPublished. + +The productStatus and productType chips are editable: clicking a chip opens a dropdown to change the value inline via a mutation. + +The mainImage column is excluded from Excel export (block data is not exportable). + +The grid should support search by name and sku, filtering by productStatus and productType, and Excel export. + +## Form + +All fields in a single form. Group into FieldSets: + +- "General": name, slug, description +- "Details": sku, price, productType (SelectField) +- "Publishing": productStatus (SelectField), publishedAt, isPublished +- "Media": mainImage + +Field validations: + +- price: must be positive (client-side validator) +- sku: must match format `[A-Z]{2,4}-[0-9]{4,8}` (client-side validator) + +## Pages + +Grid with edit on separate page (StackSwitch with grid, add, and edit pages). The grid toolbar includes an "Add Product" button that navigates to the add page. The entity toolbar shows the product name with the SKU as support text. + +## Acceptance Criteria + +- Products can be created, edited, deleted, and listed. +- Search finds products by name or SKU. +- Grid can be filtered by productStatus and productType. +- Grid data can be exported to Excel. +- Slug and SKU uniqueness are validated server-side and return field-level errors. +- Price and SKU format are validated client-side with inline error messages. +- The DAM image is shown as a thumbnail in the grid and with a preview in the form. +- productStatus and productType can be changed directly in the grid via editable chips.