Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions admin/src/common/validators/validateNonNegativeInteger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type ReactElement } from "react";
import { FormattedMessage } from "react-intl";

export const validateNonNegativeInteger = (value: number | undefined): ReactElement | undefined => {
if (value == null) return undefined;
if (!Number.isInteger(value) || value < 0) {
return <FormattedMessage id="validation.nonNegativeInteger" defaultMessage="Value must be zero or a positive whole number" />;
}
return undefined;
};
43 changes: 42 additions & 1 deletion admin/src/products/ProductsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
Button,
FillSpace,
FullHeightContent,
RouterTab,
RouterTabs,
SaveBoundary,
Stack,
StackLink,
Expand All @@ -20,6 +23,9 @@ import { FormattedMessage } from "react-intl";
import { ProductForm } from "./components/productForm/ProductForm";
import { ProductsGrid } from "./components/productsDataGrid/ProductsGrid";
import { ProductToolbar } from "./components/productToolbar/ProductToolbar";
import { ProductVariantForm } from "./components/productVariantForm/ProductVariantForm";
import { ProductVariantsGrid } from "./components/productVariantsDataGrid/ProductVariantsGrid";
import { ProductVariantToolbar } from "./components/productVariantToolbar/ProductVariantToolbar";

export const ProductsPage: FunctionComponent = () => {
return (
Expand Down Expand Up @@ -53,7 +59,42 @@ export const ProductsPage: FunctionComponent = () => {
<SaveBoundary>
<ProductToolbar id={id} />
<StackMainContent>
<ProductForm id={id} />
<RouterTabs>
<RouterTab
forceRender={true}
path=""
label={<FormattedMessage id="products.tab.product" defaultMessage="Product" />}
>
<ProductForm id={id} />
</RouterTab>
<RouterTab path="/variants" label={<FormattedMessage id="products.tab.variants" defaultMessage="Variants" />}>
<StackSwitch>
<StackPage name="grid">
<FullHeightContent>
<ProductVariantsGrid productId={id} />
</FullHeightContent>
</StackPage>
<StackPage name="add">
<SaveBoundary>
<ProductVariantToolbar />
<StackMainContent>
<ProductVariantForm productId={id} />
</StackMainContent>
</SaveBoundary>
</StackPage>
<StackPage name="edit">
{(variantId) => (
<SaveBoundary>
<ProductVariantToolbar id={variantId} />
<StackMainContent>
<ProductVariantForm id={variantId} />
</StackMainContent>
</SaveBoundary>
)}
</StackPage>
</StackSwitch>
</RouterTab>
</RouterTabs>
</StackMainContent>
</SaveBoundary>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { gql } from "@apollo/client";

export const productVariantFormFragment = gql`
fragment ProductVariantFormDetails on ProductVariant {
name
sku
variantStatus
price
stock
isAvailable
}
`;

export const productVariantQuery = gql`
query ProductVariant($id: ID!) {
productVariant(id: $id) {
id
updatedAt
image
...ProductVariantFormDetails
}
}
${productVariantFormFragment}
`;

export const createProductVariantMutation = gql`
mutation CreateProductVariant($product: ID!, $input: ProductVariantInput!) {
createProductVariant(product: $product, input: $input) {
productVariant {
id
updatedAt
...ProductVariantFormDetails
}
errors {
code
field
}
}
}
${productVariantFormFragment}
`;

export const updateProductVariantMutation = gql`
mutation UpdateProductVariant($id: ID!, $input: ProductVariantUpdateInput!) {
updateProductVariant(id: $id, input: $input) {
productVariant {
id
updatedAt
...ProductVariantFormDetails
}
errors {
code
field
}
}
}
${productVariantFormFragment}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { useApolloClient, useQuery } from "@apollo/client";
import {
Field,
FieldSet,
filterByFragment,
FinalForm,
type FinalFormSubmitEvent,
Loading,
NumberField,
SwitchField,
TextField,
useFormApiRef,
useStackSwitchApi,
} from "@comet/admin";
import { type BlockState, createFinalFormBlock, DamImageBlock, queryUpdatedAt, resolveHasSaveConflict, useFormSaveConflict } from "@comet/cms-admin";
import { InputAdornment } from "@mui/material";
import { validateNonNegativeInteger } from "@src/common/validators/validateNonNegativeInteger";
import { validatePositiveNumber } from "@src/common/validators/validatePositiveNumber";
import { VariantStatusSelectField } from "@src/products/components/variantStatusSelectField/VariantStatusSelectField";
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 {
createProductVariantMutation,
productVariantFormFragment,
productVariantQuery,
updateProductVariantMutation,
} from "./ProductVariantForm.gql";
import {
type GQLCreateProductVariantMutation,
type GQLCreateProductVariantMutationVariables,
type GQLProductVariantFormDetailsFragment,
type GQLProductVariantQuery,
type GQLProductVariantQueryVariables,
type GQLUpdateProductVariantMutation,
type GQLUpdateProductVariantMutationVariables,
} from "./ProductVariantForm.gql.generated";

const rootBlocks = {
image: DamImageBlock,
};

type FormValues = GQLProductVariantFormDetailsFragment & {
image: BlockState<typeof rootBlocks.image>;
};

interface FormProps {
id?: string;
productId?: string;
}

export function ProductVariantForm({ id, productId }: FormProps) {
const client = useApolloClient();
const mode = id ? "edit" : "add";
const formApiRef = useFormApiRef<FormValues>();
const stackSwitchApi = useStackSwitchApi();

const { data, error, loading, refetch } = useQuery<GQLProductVariantQuery, GQLProductVariantQueryVariables>(
productVariantQuery,
id ? { variables: { id } } : { skip: true },
);

const initialValues = useMemo<Partial<FormValues>>(
() =>
data?.productVariant
? {
...filterByFragment<GQLProductVariantFormDetailsFragment>(productVariantFormFragment, data.productVariant),
image: data.productVariant.image ? rootBlocks.image.input2State(data.productVariant.image) : rootBlocks.image.defaultValues(),
}
: {
isAvailable: true,
stock: 0,
image: rootBlocks.image.defaultValues(),
},
[data],
);

const saveConflict = useFormSaveConflict({
checkConflict: async () => {
const updatedAt = await queryUpdatedAt(client, "productVariant", id);
return resolveHasSaveConflict(data?.productVariant.updatedAt, updatedAt);
},
formApiRef,
loadLatestVersion: async () => {
await refetch();
},
});

const handleSubmit = async (formValues: FormValues, form: FormApi<FormValues>, event: FinalFormSubmitEvent) => {
if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected");

const output = {
...formValues,
image: rootBlocks.image.state2Output(formValues.image),
};

if (mode === "edit") {
if (!id) throw new Error();
const { data: mutationResponse } = await client.mutate<GQLUpdateProductVariantMutation, GQLUpdateProductVariantMutationVariables>({
mutation: updateProductVariantMutation,
variables: { id, input: output },
});

if (mutationResponse?.updateProductVariant.errors.length) {
return mutationResponse.updateProductVariant.errors.reduce(
(submissionErrors, error) => {
if (error.field) {
submissionErrors[error.field] = error.code;
} else {
submissionErrors[FORM_ERROR] = error.code;
}
return submissionErrors;
},
{} as Record<string, ReactNode>,
);
}
} else {
if (!productId) throw new Error("productId is required for creating a product variant");
const { data: mutationResponse } = await client.mutate<GQLCreateProductVariantMutation, GQLCreateProductVariantMutationVariables>({
mutation: createProductVariantMutation,
variables: { product: productId, input: output },
});

if (mutationResponse?.createProductVariant.errors.length) {
return mutationResponse.createProductVariant.errors.reduce(
(submissionErrors, error) => {
if (error.field) {
submissionErrors[error.field] = error.code;
} else {
submissionErrors[FORM_ERROR] = error.code;
}
return submissionErrors;
},
{} as Record<string, ReactNode>,
);
}

const newId = mutationResponse?.createProductVariant.productVariant?.id;
if (newId) {
setTimeout(() => stackSwitchApi.activatePage("edit", newId));
}
}
};

if (error) throw error;
if (loading) return <Loading behavior="fillPageHeight" />;

return (
<FinalForm<FormValues>
apiRef={formApiRef}
onSubmit={handleSubmit}
mode={mode}
initialValues={initialValues}
initialValuesEqual={isEqual}
subscription={{}}
>
{() => (
<>
{saveConflict.dialogs}
<FieldSet initiallyExpanded title={<FormattedMessage id="productVariant.fieldSet.general" defaultMessage="General" />}>
<TextField
required
variant="horizontal"
fullWidth
name="name"
label={<FormattedMessage id="productVariant.name" defaultMessage="Name" />}
/>
<TextField
required
variant="horizontal"
fullWidth
name="sku"
label={<FormattedMessage id="productVariant.sku" defaultMessage="SKU" />}
/>
<VariantStatusSelectField
required
variant="horizontal"
fullWidth
name="variantStatus"
label={<FormattedMessage id="productVariant.variantStatus" defaultMessage="Status" />}
/>
</FieldSet>
<FieldSet
initiallyExpanded
title={<FormattedMessage id="productVariant.fieldSet.pricingAndStock" defaultMessage="Pricing & Stock" />}
>
<NumberField
required
variant="horizontal"
fullWidth
name="price"
label={<FormattedMessage id="productVariant.price" defaultMessage="Price" />}
validate={validatePositiveNumber}
endAdornment={<InputAdornment position="end">&euro;</InputAdornment>}
/>
<NumberField
required
variant="horizontal"
fullWidth
name="stock"
label={<FormattedMessage id="productVariant.stock" defaultMessage="Stock" />}
validate={validateNonNegativeInteger}
/>
<SwitchField
name="isAvailable"
fieldLabel={<FormattedMessage id="productVariant.isAvailable" defaultMessage="Available" />}
variant="horizontal"
fullWidth
/>
</FieldSet>
<FieldSet initiallyExpanded title={<FormattedMessage id="productVariant.fieldSet.media" defaultMessage="Media" />}>
<Field
name="image"
isEqual={isEqual}
label={<FormattedMessage id="productVariant.image" defaultMessage="Image" />}
variant="horizontal"
fullWidth
>
{createFinalFormBlock(rootBlocks.image)}
</Field>
</FieldSet>
</>
)}
</FinalForm>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { gql } from "@apollo/client";

export const productVariantToolbarQuery = gql`
query ProductVariantToolbar($id: ID!) {
productVariant(id: $id) {
id
name
sku
}
}
`;
Loading