From 0ea93575d979a911a9e49336b219b3ab29d3dd31 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 08:37:22 +0100 Subject: [PATCH 1/8] add product variant specification --- specs/product/03-product-variant.md | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 specs/product/03-product-variant.md diff --git a/specs/product/03-product-variant.md b/specs/product/03-product-variant.md new file mode 100644 index 000000000..daf0a469d --- /dev/null +++ b/specs/product/03-product-variant.md @@ -0,0 +1,75 @@ +--- +title: "03 - Product Variant" +--- + +# Product Variant + +The ProductVariant entity represents a specific variant of a Product (e.g., size, color). Variants are managed as sub-entities within the Product edit page. + +## Fields + +- name: string, required +- sku: string, required +- price: number, required, decimal +- stock: number, required, integer, default 0 +- isAvailable: boolean, default true +- image: file upload, optional + +## Enums + +- variantStatus: Active, OutOfStock, Discontinued + +## Relations + +- product: ManyToOne to Product, required (parent entity) + +## Requirements + +- The entity is scoped via its parent Product (`@ScopedEntity` deriving scope from product relation). +- Variants are sub-entities of Product — they are always viewed in the context of a specific product. +- The variantStatus should be editable inline in the grid via an editable chip. +- SKU must be unique within the same product. +- Stock must not be negative. + +## API Validation + +- SKU uniqueness must be validated server-side within the parent product. Return a field-level error `SKU_ALREADY_EXISTS` if another variant of the same product has the same SKU. + +## DataGrid + +Sub-entity grid filtered by the parent product ID. + +Columns: image as thumbnail, name, sku, price, stock, variantStatus as editable chip, isAvailable. + +The grid should support search by name and sku. + +## Form + +All fields except product (implicitly set from parent). Group into FieldSets: + +- "General": name, sku, variantStatus (SelectField) +- "Pricing & Stock": price, stock, isAvailable +- "Media": image + +Field validations: + +- price: must be positive (client-side validator) +- stock: must be zero or positive integer (client-side validator) + +## Pages + +The ProductVariant grid and form are embedded as a RouterTab ("Variants") within the Product edit page. The variant edit uses a nested StackSwitch inside the tab. The variant edit page shows the variant name in the entity toolbar. + +## Products DataGrid Integration + +The Products DataGrid shows a `variantCount` column displaying the number of variants per product in a grey Chip. + +## Acceptance Criteria + +- Variants are only accessible from within the Product edit page. +- The variant grid only shows variants belonging to the current product. +- variantStatus can be changed inline from the grid via an editable chip (no need to open the form). +- A new RouterTab "Variants" appears on the Product edit page alongside the existing Product form tab. +- SKU uniqueness within the product is enforced server-side with a field-level error. +- Price and stock are validated client-side with inline error messages. +- The Products DataGrid shows the variant count per product in a grey Chip column. From 14380aeb14fd1c7652ad93e94e8a1bda0774566d Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 08:45:56 +0100 Subject: [PATCH 2/8] Add ProductVariant entity, service, resolver, and DTOs --- api/schema.gql | 101 +++++++++++++ .../db/migrations/Migration20260316074455.ts | 15 ++ .../dto/paginated-product-variants.ts | 6 + .../products/dto/product-variant.filter.ts | 65 +++++++++ api/src/products/dto/product-variant.input.ts | 48 ++++++ api/src/products/dto/product-variant.sort.ts | 28 ++++ api/src/products/dto/product-variants.args.ts | 29 ++++ .../entities/product-variant.entity.ts | 70 +++++++++ api/src/products/entities/product.entity.ts | 7 +- api/src/products/product-variant.resolver.ts | 100 +++++++++++++ api/src/products/product-variants.service.ts | 137 ++++++++++++++++++ api/src/products/product.resolver.ts | 14 +- api/src/products/products.module.ts | 7 +- 13 files changed, 622 insertions(+), 5 deletions(-) create mode 100644 api/src/db/migrations/Migration20260316074455.ts create mode 100644 api/src/products/dto/paginated-product-variants.ts create mode 100644 api/src/products/dto/product-variant.filter.ts create mode 100644 api/src/products/dto/product-variant.input.ts create mode 100644 api/src/products/dto/product-variant.sort.ts create mode 100644 api/src/products/dto/product-variants.args.ts create mode 100644 api/src/products/entities/product-variant.entity.ts create mode 100644 api/src/products/product-variant.resolver.ts create mode 100644 api/src/products/product-variants.service.ts diff --git a/api/schema.gql b/api/schema.gql index b7f443761..772886482 100644 --- a/api/schema.gql +++ b/api/schema.gql @@ -36,6 +36,11 @@ type CreateProductPayload { product: Product } +type CreateProductVariantPayload { + errors: [ProductVariantValidationError!]! + productVariant: ProductVariant +} + type CurrentUser { accountUrl: String allowedContentScopes: [ContentScopeWithLabel!]! @@ -371,6 +376,7 @@ type Mutation { createPageTreeNode(category: String!, input: PageTreeNodeCreateInput!, scope: PageTreeNodeScopeInput!): PageTreeNode! createProduct(input: ProductInput!, scope: ProductScopeInput!): CreateProductPayload! createProductCategory(input: ProductCategoryInput!, scope: ProductCategoryScopeInput!): CreateProductCategoryPayload! + createProductVariant(input: ProductVariantInput!, product: ID!): CreateProductVariantPayload! createRedirect(input: RedirectInput!, scope: RedirectScopeInput!): Redirect! currentUserSignOut: String! deleteDamFile(id: ID!): Boolean! @@ -379,6 +385,7 @@ type Mutation { deletePageTreeNode(id: ID!): Boolean! deleteProduct(id: ID!): Boolean! deleteProductCategory(id: ID!): Boolean! + deleteProductVariant(id: ID!): Boolean! deleteRedirect(id: ID!): Boolean! importDamFileByDownload(input: UpdateDamFileInput!, scope: DamScopeInput! = {}, url: String!): DamFile! moveDamFiles(fileIds: [ID!]!, targetFolderId: ID): [DamFile!]! @@ -399,6 +406,7 @@ type Mutation { updatePageTreeNodeVisibility(id: ID!, input: PageTreeNodeUpdateVisibilityInput!): PageTreeNode! updateProduct(id: ID!, input: ProductUpdateInput!): UpdateProductPayload! updateProductCategory(id: ID!, input: ProductCategoryUpdateInput!): UpdateProductCategoryPayload! + updateProductVariant(id: ID!, input: ProductVariantUpdateInput!): UpdateProductVariantPayload! updateRedirect(id: ID!, input: RedirectInput!, lastUpdatedAt: DateTime): Redirect! updateRedirectActiveness(id: ID!, input: RedirectUpdateActivenessInput!): Redirect! userPermissionsCreatePermission(input: UserPermissionInput!, userId: String!): UserPermission! @@ -552,6 +560,11 @@ type PaginatedProductCategories { totalCount: Int! } +type PaginatedProductVariants { + nodes: [ProductVariant!]! + totalCount: Int! +} + type PaginatedProducts { nodes: [Product!]! totalCount: Int! @@ -608,6 +621,7 @@ type Product { sku: String! slug: String! updatedAt: DateTime! + variantCount: Int! } type ProductCategory { @@ -780,6 +794,74 @@ enum ProductValidationErrorCode { SLUG_ALREADY_EXISTS } +type ProductVariant { + createdAt: DateTime! + id: ID! + image: DamImageBlockData + isAvailable: Boolean! + name: String! + price: Float! + product: Product! + sku: String! + stock: Int! + updatedAt: DateTime! + variantStatus: VariantStatus! +} + +input ProductVariantFilter { + and: [ProductVariantFilter!] + id: IdFilter + isAvailable: BooleanFilter + name: StringFilter + or: [ProductVariantFilter!] + price: NumberFilter + sku: StringFilter + stock: NumberFilter + variantStatus: VariantStatusEnumFilter +} + +input ProductVariantInput { + image: DamImageBlockInput + isAvailable: Boolean! = true + name: String! + price: Float! + sku: String! + stock: Int! = 0 + variantStatus: VariantStatus! = Active +} + +input ProductVariantSort { + direction: SortDirection! = ASC + field: ProductVariantSortField! +} + +enum ProductVariantSortField { + createdAt + id + isAvailable + name + price + sku + stock + updatedAt + variantStatus +} + +input ProductVariantUpdateInput { + image: DamImageBlockInput + isAvailable: Boolean + name: String + price: Float + sku: String + stock: Int + variantStatus: VariantStatus +} + +type ProductVariantValidationError { + code: String! + field: String +} + type Query { blockPreviewJwt(includeInvisible: Boolean!, scope: JSONObject!, url: String!): String! currentUser: CurrentUser! @@ -811,6 +893,8 @@ type Query { 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 + productVariant(id: ID!): ProductVariant! + productVariants(filter: ProductVariantFilter, limit: Int! = 25, offset: Int! = 0, product: ID!, search: String, sort: [ProductVariantSort!]! = [{direction: ASC, field: createdAt}]): PaginatedProductVariants! 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 @@ -966,6 +1050,11 @@ type UpdateProductPayload { product: Product } +type UpdateProductVariantPayload { + errors: [ProductVariantValidationError!]! + productVariant: ProductVariant +} + input UserContentScopesInput { contentScopes: [JSONObject!]! = [] } @@ -1037,6 +1126,18 @@ enum UserPermissionsUserSortField { status } +enum VariantStatus { + Active + Discontinued + OutOfStock +} + +input VariantStatusEnumFilter { + equal: VariantStatus + isAnyOf: [VariantStatus!] + notEqual: VariantStatus +} + type Warning { createdAt: DateTime! entityInfo: EntityInfo diff --git a/api/src/db/migrations/Migration20260316074455.ts b/api/src/db/migrations/Migration20260316074455.ts new file mode 100644 index 000000000..09119c350 --- /dev/null +++ b/api/src/db/migrations/Migration20260316074455.ts @@ -0,0 +1,15 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260316074455 extends Migration { + + override async up(): Promise { + this.addSql(`create table "ProductVariant" ("id" uuid not null, "name" text not null, "sku" text not null, "price" real not null, "stock" int not null default 0, "isAvailable" boolean not null default true, "variantStatus" text check ("variantStatus" in ('Active', 'OutOfStock', 'Discontinued')) not null default 'Active', "product" uuid not null, "image" json null, "createdAt" timestamptz not null, "updatedAt" timestamptz not null, constraint "ProductVariant_pkey" primary key ("id"));`); + + this.addSql(`alter table "ProductVariant" add constraint "ProductVariant_product_foreign" foreign key ("product") references "Product" ("id") on update cascade;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "ProductVariant" cascade;`); + } + +} diff --git a/api/src/products/dto/paginated-product-variants.ts b/api/src/products/dto/paginated-product-variants.ts new file mode 100644 index 000000000..a119ae99c --- /dev/null +++ b/api/src/products/dto/paginated-product-variants.ts @@ -0,0 +1,6 @@ +import { PaginatedResponseFactory } from "@comet/cms-api"; +import { ObjectType } from "@nestjs/graphql"; +import { ProductVariant } from "@src/products/entities/product-variant.entity"; + +@ObjectType() +export class PaginatedProductVariants extends PaginatedResponseFactory.create(ProductVariant) {} diff --git a/api/src/products/dto/product-variant.filter.ts b/api/src/products/dto/product-variant.filter.ts new file mode 100644 index 000000000..d24f90323 --- /dev/null +++ b/api/src/products/dto/product-variant.filter.ts @@ -0,0 +1,65 @@ +import { BooleanFilter, createEnumFilter, IdFilter, NumberFilter, StringFilter } from "@comet/cms-api"; +import { Field, InputType } from "@nestjs/graphql"; +import { VariantStatus } from "@src/products/entities/product-variant.entity"; +import { Type } from "class-transformer"; +import { IsOptional, ValidateNested } from "class-validator"; + +@InputType("VariantStatusEnumFilter") +class VariantStatusFilter extends createEnumFilter(VariantStatus) {} + +@InputType() +export class ProductVariantFilter { + @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) + sku?: StringFilter; + + @Field(() => NumberFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => NumberFilter) + price?: NumberFilter; + + @Field(() => NumberFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => NumberFilter) + stock?: NumberFilter; + + @Field(() => BooleanFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => BooleanFilter) + isAvailable?: BooleanFilter; + + @Field(() => VariantStatusFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => VariantStatusFilter) + variantStatus?: typeof VariantStatusFilter; + + @Field(() => [ProductVariantFilter], { nullable: true }) + @Type(() => ProductVariantFilter) + @ValidateNested({ each: true }) + @IsOptional() + and?: ProductVariantFilter[]; + + @Field(() => [ProductVariantFilter], { nullable: true }) + @Type(() => ProductVariantFilter) + @ValidateNested({ each: true }) + @IsOptional() + or?: ProductVariantFilter[]; +} diff --git a/api/src/products/dto/product-variant.input.ts b/api/src/products/dto/product-variant.input.ts new file mode 100644 index 000000000..73c2f4326 --- /dev/null +++ b/api/src/products/dto/product-variant.input.ts @@ -0,0 +1,48 @@ +import { BlockInputInterface, DamImageBlock, isBlockInputInterface, PartialType, RootBlockInputScalar } from "@comet/cms-api"; +import { Field, Float, InputType, Int } from "@nestjs/graphql"; +import { VariantStatus } from "@src/products/entities/product-variant.entity"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, Min, ValidateNested } from "class-validator"; + +@InputType() +export class ProductVariantInput { + @IsNotEmpty() + @IsString() + @Field() + name: string; + + @IsNotEmpty() + @IsString() + @Field() + sku: string; + + @IsNotEmpty() + @IsNumber() + @Field(() => Float) + price: number; + + @IsNotEmpty() + @IsInt() + @Min(0) + @Field(() => Int, { defaultValue: 0 }) + stock: number; + + @IsNotEmpty() + @IsBoolean() + @Field({ defaultValue: true }) + isAvailable: boolean; + + @IsNotEmpty() + @IsEnum(VariantStatus) + @Field(() => VariantStatus, { defaultValue: VariantStatus.Active }) + variantStatus: VariantStatus; + + @IsOptional() + @Field(() => RootBlockInputScalar(DamImageBlock), { nullable: true }) + @Transform(({ value }) => (isBlockInputInterface(value) ? value : DamImageBlock.blockInputFactory(value)), { toClassOnly: true }) + @ValidateNested() + image?: BlockInputInterface; +} + +@InputType() +export class ProductVariantUpdateInput extends PartialType(ProductVariantInput) {} diff --git a/api/src/products/dto/product-variant.sort.ts b/api/src/products/dto/product-variant.sort.ts new file mode 100644 index 000000000..308f904b0 --- /dev/null +++ b/api/src/products/dto/product-variant.sort.ts @@ -0,0 +1,28 @@ +import { SortDirection } from "@comet/cms-api"; +import { Field, InputType, registerEnumType } from "@nestjs/graphql"; +import { IsEnum } from "class-validator"; + +export enum ProductVariantSortField { + name = "name", + sku = "sku", + price = "price", + stock = "stock", + isAvailable = "isAvailable", + variantStatus = "variantStatus", + createdAt = "createdAt", + updatedAt = "updatedAt", + id = "id", +} + +registerEnumType(ProductVariantSortField, { name: "ProductVariantSortField" }); + +@InputType() +export class ProductVariantSort { + @Field(() => ProductVariantSortField) + @IsEnum(ProductVariantSortField) + field: ProductVariantSortField; + + @Field(() => SortDirection, { defaultValue: SortDirection.ASC }) + @IsEnum(SortDirection) + direction: SortDirection = SortDirection.ASC; +} diff --git a/api/src/products/dto/product-variants.args.ts b/api/src/products/dto/product-variants.args.ts new file mode 100644 index 000000000..c0b9f6ce2 --- /dev/null +++ b/api/src/products/dto/product-variants.args.ts @@ -0,0 +1,29 @@ +import { OffsetBasedPaginationArgs, SortDirection } from "@comet/cms-api"; +import { ArgsType, Field, ID } from "@nestjs/graphql"; +import { ProductVariantFilter } from "@src/products/dto/product-variant.filter"; +import { ProductVariantSort, ProductVariantSortField } from "@src/products/dto/product-variant.sort"; +import { Type } from "class-transformer"; +import { IsOptional, IsString, IsUUID, ValidateNested } from "class-validator"; + +@ArgsType() +export class ProductVariantsArgs extends OffsetBasedPaginationArgs { + @Field(() => ID) + @IsUUID() + product: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + search?: string; + + @Field(() => ProductVariantFilter, { nullable: true }) + @ValidateNested() + @Type(() => ProductVariantFilter) + @IsOptional() + filter?: ProductVariantFilter; + + @Field(() => [ProductVariantSort], { defaultValue: [{ field: ProductVariantSortField.createdAt, direction: SortDirection.ASC }] }) + @ValidateNested({ each: true }) + @Type(() => ProductVariantSort) + sort: ProductVariantSort[]; +} diff --git a/api/src/products/entities/product-variant.entity.ts b/api/src/products/entities/product-variant.entity.ts new file mode 100644 index 000000000..b834f7e4e --- /dev/null +++ b/api/src/products/entities/product-variant.entity.ts @@ -0,0 +1,70 @@ +import { BlockDataInterface, DamImageBlock, RootBlock, RootBlockDataScalar, RootBlockEntity, RootBlockType, ScopedEntity } from "@comet/cms-api"; +import { BaseEntity, Entity, Enum, ManyToOne, OptionalProps, PrimaryKey, Property, Ref } from "@mikro-orm/postgresql"; +import { Field, Float, ID, Int, ObjectType, registerEnumType } from "@nestjs/graphql"; +import { v4 as uuid } from "uuid"; + +import { Product } from "./product.entity"; + +export enum VariantStatus { + Active = "Active", + OutOfStock = "OutOfStock", + Discontinued = "Discontinued", +} + +registerEnumType(VariantStatus, { name: "VariantStatus" }); + +@Entity() +@ObjectType() +@RootBlockEntity() +@ScopedEntity((productVariant) => ({ + domain: productVariant.product.getEntity().domain, + language: productVariant.product.getEntity().language, +})) +export class ProductVariant extends BaseEntity { + [OptionalProps]?: "createdAt" | "updatedAt" | "stock" | "isAvailable" | "variantStatus"; + + @PrimaryKey({ type: "uuid" }) + @Field(() => ID) + id: string = uuid(); + + @Property({ type: "text" }) + @Field() + name: string; + + @Property({ type: "text" }) + @Field() + sku: string; + + @Property({ type: "float" }) + @Field(() => Float) + price: number; + + @Property({ type: "integer" }) + @Field(() => Int) + stock: number = 0; + + @Property({ type: "boolean" }) + @Field() + isAvailable: boolean = true; + + @Enum({ items: () => VariantStatus }) + @Field(() => VariantStatus) + variantStatus: VariantStatus = VariantStatus.Active; + + @ManyToOne(() => Product, { ref: true }) + @Field(() => Product) + product: Ref; + + @RootBlock(DamImageBlock) + @Property({ type: new RootBlockType(DamImageBlock), nullable: true }) + @Field(() => RootBlockDataScalar(DamImageBlock), { nullable: true }) + image?: BlockDataInterface; + + @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/entities/product.entity.ts b/api/src/products/entities/product.entity.ts index 4a48c082a..03acd258b 100644 --- a/api/src/products/entities/product.entity.ts +++ b/api/src/products/entities/product.entity.ts @@ -1,9 +1,11 @@ import { BlockDataInterface, DamImageBlock, RootBlock, RootBlockDataScalar, RootBlockEntity, RootBlockType, ScopedEntity } from "@comet/cms-api"; -import { BaseEntity, Entity, Enum, ManyToOne, OptionalProps, PrimaryKey, Property, Ref } from "@mikro-orm/postgresql"; +import { BaseEntity, Collection, Entity, Enum, ManyToOne, OneToMany, 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"; +import { ProductVariant } from "./product-variant.entity"; + export enum ProductStatus { Draft = "Draft", InReview = "InReview", @@ -88,6 +90,9 @@ export class Product extends BaseEntity { @Field() language: string; + @OneToMany(() => ProductVariant, (variant) => variant.product) + variants = new Collection(this); + @Property({ type: "timestamp with time zone" }) @Field() createdAt: Date = new Date(); diff --git a/api/src/products/product-variant.resolver.ts b/api/src/products/product-variant.resolver.ts new file mode 100644 index 000000000..23cacd8a9 --- /dev/null +++ b/api/src/products/product-variant.resolver.ts @@ -0,0 +1,100 @@ +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 { GraphQLResolveInfo } from "graphql"; + +import { PaginatedProductVariants } from "./dto/paginated-product-variants"; +import { ProductVariantInput, ProductVariantUpdateInput } from "./dto/product-variant.input"; +import { ProductVariantsArgs } from "./dto/product-variants.args"; +import { Product } from "./entities/product.entity"; +import { ProductVariant } from "./entities/product-variant.entity"; +import { ProductVariantsService, ProductVariantValidationError } from "./product-variants.service"; + +@ObjectType() +class CreateProductVariantPayload { + @Field(() => ProductVariant, { nullable: true }) + productVariant?: ProductVariant; + + @Field(() => [ProductVariantValidationError], { nullable: false }) + errors: ProductVariantValidationError[]; +} + +@ObjectType() +class UpdateProductVariantPayload { + @Field(() => ProductVariant, { nullable: true }) + productVariant?: ProductVariant; + + @Field(() => [ProductVariantValidationError], { nullable: false }) + errors: ProductVariantValidationError[]; +} + +@Resolver(() => ProductVariant) +@RequiredPermission(["products"]) +export class ProductVariantResolver { + constructor(private readonly productVariantsService: ProductVariantsService) {} + + @Query(() => ProductVariant) + @AffectedEntity(ProductVariant) + async productVariant( + @Args("id", { type: () => ID }) + id: string, + ): Promise { + return this.productVariantsService.findOneById(id); + } + + @Query(() => PaginatedProductVariants) + @AffectedEntity(Product, { idArg: "product" }) + async productVariants(@Args() args: ProductVariantsArgs, @Info() info: GraphQLResolveInfo): Promise { + const fields = extractGraphqlFields(info, { root: "nodes" }); + return this.productVariantsService.findAll(args, fields); + } + + @Mutation(() => CreateProductVariantPayload) + @AffectedEntity(Product, { idArg: "product" }) + async createProductVariant( + @Args("product", { type: () => ID }) product: string, + @Args("input", { type: () => ProductVariantInput }) input: ProductVariantInput, + @GetCurrentUser() user: CurrentUser, + ): Promise { + return this.productVariantsService.create(product, input, user); + } + + @Mutation(() => UpdateProductVariantPayload) + @AffectedEntity(ProductVariant) + async updateProductVariant( + @Args("id", { type: () => ID }) id: string, + @Args("input", { type: () => ProductVariantUpdateInput }) input: ProductVariantUpdateInput, + @GetCurrentUser() user: CurrentUser, + ): Promise { + return this.productVariantsService.update(id, input, user); + } + + @Mutation(() => Boolean) + @AffectedEntity(ProductVariant) + async deleteProductVariant( + @Args("id", { type: () => ID }) + id: string, + ): Promise { + return this.productVariantsService.delete(id); + } + + @ResolveField(() => Product) + async product(@Parent() productVariant: ProductVariant): Promise { + return productVariant.product.loadOrFail(); + } + + @ResolveField(() => RootBlockDataScalar(DamImageBlock), { nullable: true }) + async image(@Parent() productVariant: ProductVariant): Promise { + if (!productVariant.image) { + return undefined; + } + return this.productVariantsService.transformToPlain(productVariant.image); + } +} diff --git a/api/src/products/product-variants.service.ts b/api/src/products/product-variants.service.ts new file mode 100644 index 000000000..9262fd441 --- /dev/null +++ b/api/src/products/product-variants.service.ts @@ -0,0 +1,137 @@ +import { BlockDataInterface, BlocksTransformerService, CurrentUser, gqlArgsToMikroOrmQuery, gqlSortToMikroOrmOrderBy } from "@comet/cms-api"; +import { EntityManager, FindOptions, Reference } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; +import { Field, ObjectType } from "@nestjs/graphql"; + +import { PaginatedProductVariants } from "./dto/paginated-product-variants"; +import { ProductVariantInput, ProductVariantUpdateInput } from "./dto/product-variant.input"; +import { ProductVariantsArgs } from "./dto/product-variants.args"; +import { Product } from "./entities/product.entity"; +import { ProductVariant } from "./entities/product-variant.entity"; + +@ObjectType() +export class ProductVariantValidationError { + @Field({ nullable: true }) + field?: string; + + @Field() + code: string; +} + +@Injectable() +export class ProductVariantsService { + constructor( + private readonly entityManager: EntityManager, + private readonly blocksTransformer: BlocksTransformerService, + ) {} + + async findOneById(id: string): Promise { + return this.entityManager.findOneOrFail(ProductVariant, id); + } + + async findAll({ product, search, filter, sort, offset, limit }: ProductVariantsArgs, fields?: string[]): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(ProductVariant)); + where.product = product; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options: FindOptions = { offset, limit }; + if (sort) { + options.orderBy = gqlSortToMikroOrmOrderBy(sort); + } + const populate: string[] = []; + if (fields?.includes("product")) { + populate.push("product"); + } + if (populate.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (options as any).populate = populate; + } + const [entities, totalCount] = await this.entityManager.findAndCount(ProductVariant, where, options); + return new PaginatedProductVariants(entities, totalCount); + } + + async create( + product: string, + input: ProductVariantInput, + user: CurrentUser, + ): Promise<{ productVariant?: ProductVariant; errors: ProductVariantValidationError[] }> { + const errors = await this.validateCreateInput(input, { currentUser: user, args: { product } }); + if (errors.length > 0) { + return { errors }; + } + + const { image: imageInput, ...assignInput } = input; + const productVariant = this.entityManager.create(ProductVariant, { + ...assignInput, + product: Reference.create(await this.entityManager.findOneOrFail(Product, product)), + ...(imageInput ? { image: imageInput.transformToBlockData() } : {}), + }); + await this.entityManager.flush(); + return { productVariant, errors: [] }; + } + + async update( + id: string, + input: ProductVariantUpdateInput, + user: CurrentUser, + ): Promise<{ productVariant?: ProductVariant; errors: ProductVariantValidationError[] }> { + const productVariant = await this.entityManager.findOneOrFail(ProductVariant, id); + + const errors = await this.validateUpdateInput(input, { currentUser: user, entity: productVariant }); + if (errors.length > 0) { + return { errors }; + } + + const { image: imageInput, ...assignInput } = input; + productVariant.assign({ ...assignInput }); + if (imageInput) { + productVariant.image = imageInput.transformToBlockData(); + } + await this.entityManager.flush(); + return { productVariant, errors: [] }; + } + + async delete(id: string): Promise { + const productVariant = await this.entityManager.findOneOrFail(ProductVariant, id); + this.entityManager.remove(productVariant); + await this.entityManager.flush(); + return true; + } + + async transformToPlain(blockData: BlockDataInterface): Promise { + return this.blocksTransformer.transformToPlain(blockData); + } + + private async validateCreateInput( + input: ProductVariantInput, + context: { currentUser: CurrentUser; args: { product: string } }, + ): Promise { + const errors: ProductVariantValidationError[] = []; + + const existingSku = await this.entityManager.findOne(ProductVariant, { sku: input.sku, product: context.args.product }); + if (existingSku) { + errors.push({ field: "sku", code: "SKU_ALREADY_EXISTS" }); + } + + return errors; + } + + private async validateUpdateInput( + input: ProductVariantUpdateInput, + context: { currentUser: CurrentUser; entity: ProductVariant }, + ): Promise { + const errors: ProductVariantValidationError[] = []; + + if (input.sku !== undefined) { + const existingSku = await this.entityManager.findOne(ProductVariant, { + sku: input.sku, + product: context.entity.product, + id: { $ne: context.entity.id }, + }); + if (existingSku) { + errors.push({ field: "sku", code: "SKU_ALREADY_EXISTS" }); + } + } + + return errors; + } +} diff --git a/api/src/products/product.resolver.ts b/api/src/products/product.resolver.ts index 623edb82e..29c6a3309 100644 --- a/api/src/products/product.resolver.ts +++ b/api/src/products/product.resolver.ts @@ -7,12 +7,14 @@ import { RequiredPermission, RootBlockDataScalar, } from "@comet/cms-api"; -import { Args, Field, ID, Info, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { EntityManager } from "@mikro-orm/postgresql"; +import { Args, Field, ID, Info, Int, 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 { ProductVariant } from "@src/products/entities/product-variant.entity"; import { GraphQLResolveInfo } from "graphql"; import { PaginatedProducts } from "./dto/paginated-products"; @@ -39,7 +41,10 @@ class UpdateProductPayload { @Resolver(() => Product) @RequiredPermission(["products"]) export class ProductResolver { - constructor(private readonly productsService: ProductsService) {} + constructor( + private readonly productsService: ProductsService, + private readonly entityManager: EntityManager, + ) {} @Query(() => Product) @AffectedEntity(Product) @@ -101,4 +106,9 @@ export class ProductResolver { } return this.productsService.transformToPlain(product.mainImage); } + + @ResolveField(() => Int) + async variantCount(@Parent() product: Product): Promise { + return this.entityManager.count(ProductVariant, { product: product.id }); + } } diff --git a/api/src/products/products.module.ts b/api/src/products/products.module.ts index 30db8f616..2fa7781d8 100644 --- a/api/src/products/products.module.ts +++ b/api/src/products/products.module.ts @@ -3,11 +3,14 @@ import { Module } from "@nestjs/common"; import { ProductCategory } from "@src/product-categories/entities/product-category.entity"; import { Product } from "./entities/product.entity"; +import { ProductVariant } from "./entities/product-variant.entity"; import { ProductResolver } from "./product.resolver"; +import { ProductVariantResolver } from "./product-variant.resolver"; +import { ProductVariantsService } from "./product-variants.service"; import { ProductsService } from "./products.service"; @Module({ - imports: [MikroOrmModule.forFeature([Product, ProductCategory])], - providers: [ProductsService, ProductResolver], + imports: [MikroOrmModule.forFeature([Product, ProductCategory, ProductVariant])], + providers: [ProductsService, ProductResolver, ProductVariantsService, ProductVariantResolver], }) export class ProductsModule {} From c7d975acf3bfd06cbc05be5e7c92688876f63d72 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 08:47:22 +0100 Subject: [PATCH 3/8] Add translatable enum components for VariantStatus --- .../variantStatus/VariantStatus.tsx | 15 +++++ .../variantStatusChip/VariantStatusChip.tsx | 54 +++++++++++++++++ ...StatusChipEditableForProductVariant.gql.ts | 21 +++++++ ...antStatusChipEditableForProductVariant.tsx | 58 +++++++++++++++++++ .../VariantStatusSelectField.tsx | 13 +++++ 5 files changed, 161 insertions(+) create mode 100644 admin/src/products/components/variantStatus/VariantStatus.tsx create mode 100644 admin/src/products/components/variantStatusChip/VariantStatusChip.tsx create mode 100644 admin/src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant.gql.ts create mode 100644 admin/src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant.tsx create mode 100644 admin/src/products/components/variantStatusSelectField/VariantStatusSelectField.tsx diff --git a/admin/src/products/components/variantStatus/VariantStatus.tsx b/admin/src/products/components/variantStatus/VariantStatus.tsx new file mode 100644 index 000000000..c3c58e7e8 --- /dev/null +++ b/admin/src/products/components/variantStatus/VariantStatus.tsx @@ -0,0 +1,15 @@ +import { createTranslatableEnum } from "@src/common/components/enums/createTranslatableEnum/createTranslatableEnum"; +import { type GQLVariantStatus } from "@src/graphql.generated"; +import { defineMessage } from "react-intl"; + +const { + messageDescriptorMap, + formattedMessageMap, + Component: VariantStatus, +} = createTranslatableEnum({ + Active: defineMessage({ defaultMessage: "Active", id: "products.variantStatus.active" }), + OutOfStock: defineMessage({ defaultMessage: "Out of Stock", id: "products.variantStatus.outOfStock" }), + Discontinued: defineMessage({ defaultMessage: "Discontinued", id: "products.variantStatus.discontinued" }), +}); + +export { VariantStatus, formattedMessageMap as variantStatusFormattedMessageMap, messageDescriptorMap as variantStatusMessageDescriptorMap }; diff --git a/admin/src/products/components/variantStatusChip/VariantStatusChip.tsx b/admin/src/products/components/variantStatusChip/VariantStatusChip.tsx new file mode 100644 index 000000000..b2531dfcc --- /dev/null +++ b/admin/src/products/components/variantStatusChip/VariantStatusChip.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 GQLVariantStatus } from "@src/graphql.generated"; +import { VariantStatus, variantStatusFormattedMessageMap } from "@src/products/components/variantStatus/VariantStatus"; +import { type FunctionComponent } from "react"; + +type VariantStatusChipProps = Pick, "loading" | "onSelectItem" | "value">; + +const variantStatusSortOrder: GQLVariantStatus[] = ["Active", "OutOfStock", "Discontinued"]; + +export const VariantStatusChip: FunctionComponent = ({ loading, onSelectItem, value }) => { + return ( + + chipMap={{ + Active: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + OutOfStock: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + Discontinued: (chipProps) => ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ), + }} + formattedMessageMap={variantStatusFormattedMessageMap} + loading={loading} + onSelectItem={onSelectItem} + sortOrder={variantStatusSortOrder} + value={value} + /> + ); +}; diff --git a/admin/src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant.gql.ts b/admin/src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant.gql.ts new file mode 100644 index 000000000..d6bc39bb1 --- /dev/null +++ b/admin/src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant.gql.ts @@ -0,0 +1,21 @@ +import { gql } from "@apollo/client"; + +export const variantStatusForProductVariantQuery = gql` + query VariantStatusForProductVariant($id: ID!) { + productVariant(id: $id) { + id + variantStatus + } + } +`; + +export const updateProductVariantVariantStatusMutation = gql` + mutation UpdateProductVariantVariantStatus($id: ID!, $variantStatus: VariantStatus!) { + updateProductVariant(id: $id, input: { variantStatus: $variantStatus }) { + productVariant { + id + variantStatus + } + } + } +`; diff --git a/admin/src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant.tsx b/admin/src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant.tsx new file mode 100644 index 000000000..5c0f9d240 --- /dev/null +++ b/admin/src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant.tsx @@ -0,0 +1,58 @@ +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 { VariantStatusChip } from "@src/products/components/variantStatusChip/VariantStatusChip"; +import { type FunctionComponent } from "react"; + +import { updateProductVariantVariantStatusMutation, variantStatusForProductVariantQuery } from "./VariantStatusChipEditableForProductVariant.gql"; +import { + type GQLUpdateProductVariantVariantStatusMutation, + type GQLUpdateProductVariantVariantStatusMutationVariables, + type GQLVariantStatusForProductVariantQuery, + type GQLVariantStatusForProductVariantQueryVariables, +} from "./VariantStatusChipEditableForProductVariant.gql.generated"; + +type VariantStatusChipEditableForProductVariantProps = { + productVariantId: string; +}; + +export const VariantStatusChipEditableForProductVariant: FunctionComponent = ({ + productVariantId, +}) => { + const { data, loading, error } = useQuery( + variantStatusForProductVariantQuery, + { + variables: { id: productVariantId }, + context: LocalErrorScopeApolloContext, + }, + ); + const [updateMutation, { loading: updateLoading }] = useMutation< + GQLUpdateProductVariantVariantStatusMutation, + GQLUpdateProductVariantVariantStatusMutationVariables + >(updateProductVariantVariantStatusMutation); + + if (error) { + return ( + + + + } + variant="light" + > + + + ); + } + return data?.productVariant.variantStatus ? ( + { + updateMutation({ variables: { id: productVariantId, variantStatus } }); + }} + /> + ) : null; +}; diff --git a/admin/src/products/components/variantStatusSelectField/VariantStatusSelectField.tsx b/admin/src/products/components/variantStatusSelectField/VariantStatusSelectField.tsx new file mode 100644 index 000000000..d3046b9c4 --- /dev/null +++ b/admin/src/products/components/variantStatusSelectField/VariantStatusSelectField.tsx @@ -0,0 +1,13 @@ +import { SelectField, type SelectFieldProps } from "@comet/admin"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions/recordToOptions"; +import { type GQLVariantStatus } from "@src/graphql.generated"; +import { variantStatusFormattedMessageMap } from "@src/products/components/variantStatus/VariantStatus"; +import { type FunctionComponent } from "react"; + +export type VariantStatusFormState = GQLVariantStatus; + +type VariantStatusSelectFieldProps = Omit, "options">; + +export const VariantStatusSelectField: FunctionComponent = ({ name, ...restProps }) => { + return ; +}; From fdf2cb6cfbfb222be0eafe8ea3b993978a6478e6 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 08:57:43 +0100 Subject: [PATCH 4/8] Add ProductVariants DataGrid with toolbar --- .../ProductVariantsGrid.gql.ts | 39 ++++ .../ProductVariantsGrid.tsx | 195 ++++++++++++++++++ .../toolbar/ProductVariantsGridToolbar.tsx | 17 ++ 3 files changed, 251 insertions(+) create mode 100644 admin/src/products/components/productVariantsDataGrid/ProductVariantsGrid.gql.ts create mode 100644 admin/src/products/components/productVariantsDataGrid/ProductVariantsGrid.tsx create mode 100644 admin/src/products/components/productVariantsDataGrid/toolbar/ProductVariantsGridToolbar.tsx diff --git a/admin/src/products/components/productVariantsDataGrid/ProductVariantsGrid.gql.ts b/admin/src/products/components/productVariantsDataGrid/ProductVariantsGrid.gql.ts new file mode 100644 index 000000000..3b98faaca --- /dev/null +++ b/admin/src/products/components/productVariantsDataGrid/ProductVariantsGrid.gql.ts @@ -0,0 +1,39 @@ +import { gql } from "@apollo/client"; + +const productVariantsFragment = gql` + fragment ProductVariantsGridItem on ProductVariant { + id + image + name + sku + price + stock + variantStatus + isAvailable + } +`; + +export const productVariantsQuery = gql` + query ProductVariantsGrid( + $product: ID! + $offset: Int! + $limit: Int! + $sort: [ProductVariantSort!]! + $search: String + $filter: ProductVariantFilter + ) { + productVariants(product: $product, offset: $offset, limit: $limit, sort: $sort, search: $search, filter: $filter) { + nodes { + ...ProductVariantsGridItem + } + totalCount + } + } + ${productVariantsFragment} +`; + +export const deleteProductVariantMutation = gql` + mutation DeleteProductVariant($id: ID!) { + deleteProductVariant(id: $id) + } +`; diff --git a/admin/src/products/components/productVariantsDataGrid/ProductVariantsGrid.tsx b/admin/src/products/components/productVariantsDataGrid/ProductVariantsGrid.tsx new file mode 100644 index 000000000..ab6a58639 --- /dev/null +++ b/admin/src/products/components/productVariantsDataGrid/ProductVariantsGrid.tsx @@ -0,0 +1,195 @@ +import { useApolloClient, useQuery } from "@apollo/client"; +import { + CrudContextMenu, + type GridColDef, + muiGridFilterToGql, + muiGridSortToGql, + StackLink, + Tooltip, + useBufferedRowCount, + useDataGridRemote, + usePersistentColumnState, + useStackSwitchApi, +} from "@comet/admin"; +import { Edit as EditIcon, Info as InfoIcon } from "@comet/admin-icons"; +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 { variantStatusMessageDescriptorMap } from "@src/products/components/variantStatus/VariantStatus"; +import { VariantStatusChipEditableForProductVariant } from "@src/products/components/variantStatusChipEditableForProductVariant/VariantStatusChipEditableForProductVariant"; +import { useMemo } from "react"; +import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; + +import { deleteProductVariantMutation, productVariantsQuery } from "./ProductVariantsGrid.gql"; +import { + type GQLDeleteProductVariantMutation, + type GQLDeleteProductVariantMutationVariables, + type GQLProductVariantsGridItemFragment, + type GQLProductVariantsGridQuery, + type GQLProductVariantsGridQueryVariables, +} from "./ProductVariantsGrid.gql.generated"; +import { ProductVariantsGridToolbar } from "./toolbar/ProductVariantsGridToolbar"; + +type ProductVariantsGridProps = { + productId: string; +}; + +export function ProductVariantsGrid({ productId }: ProductVariantsGridProps) { + const client = useApolloClient(); + const intl = useIntl(); + const dataGridProps = { + ...useDataGridRemote({ + queryParamsPrefix: "productVariants", + }), + ...usePersistentColumnState("ProductVariantsGrid"), + }; + const stackSwitchApi = useStackSwitchApi(); + + const handleRowClick: DataGridProProps["onRowClick"] = (params) => { + stackSwitchApi.activatePage("edit", params.row.id); + }; + + const columns: GridColDef[] = useMemo( + () => [ + { + field: "image", + headerName: intl.formatMessage({ id: "productVariant.image", defaultMessage: "Image" }), + sortable: false, + filterable: false, + disableExport: true, + width: 80, + renderCell: ({ row }) => { + const damFile = row.image?.attachedBlocks?.[0]?.props?.damFile; + if (!damFile?.fileUrl) return null; + return ( + + + + ); + }, + }, + { + field: "name", + headerName: intl.formatMessage({ id: "productVariant.name", defaultMessage: "Name" }), + flex: 1, + minWidth: 200, + }, + { + field: "sku", + headerName: intl.formatMessage({ id: "productVariant.sku", defaultMessage: "SKU" }), + width: 150, + }, + { + field: "price", + renderHeader: () => ( + <> + + }> + + + + ), + headerName: intl.formatMessage({ id: "productVariant.price", defaultMessage: "Price" }), + type: "number", + renderCell: ({ value }) => { + return typeof value === "number" ? ( + + ) : ( + "" + ); + }, + width: 150, + }, + { + field: "stock", + headerName: intl.formatMessage({ id: "productVariant.stock", defaultMessage: "Stock" }), + type: "number", + width: 120, + }, + { + field: "variantStatus", + headerName: intl.formatMessage({ id: "productVariant.variantStatus", defaultMessage: "Status" }), + type: "singleSelect", + valueOptions: messageDescriptorMapToValueOptions(variantStatusMessageDescriptorMap, intl), + width: 160, + renderCell: ({ row }) => ( + e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> + + + ), + }, + { + field: "isAvailable", + headerName: intl.formatMessage({ id: "productVariant.isAvailable", defaultMessage: "Available" }), + 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: deleteProductVariantMutation, + variables: { id: params.row.id }, + }); + }} + refetchQueries={[productVariantsQuery]} + /> + + ); + }, + }, + ], + [intl, client], + ); + + const { filter: gqlFilter, search: gqlSearch } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const { data, loading, error } = useQuery(productVariantsQuery, { + variables: { + product: productId, + filter: gqlFilter, + search: gqlSearch, + sort: muiGridSortToGql(dataGridProps.sortModel, columns) ?? [], + offset: dataGridProps.paginationModel.page * dataGridProps.paginationModel.pageSize, + limit: dataGridProps.paginationModel.pageSize, + }, + }); + const rowCount = useBufferedRowCount(data?.productVariants.totalCount); + if (error) throw error; + const rows = data?.productVariants.nodes ?? []; + + return ( + + ); +} diff --git a/admin/src/products/components/productVariantsDataGrid/toolbar/ProductVariantsGridToolbar.tsx b/admin/src/products/components/productVariantsDataGrid/toolbar/ProductVariantsGridToolbar.tsx new file mode 100644 index 000000000..8746f85c9 --- /dev/null +++ b/admin/src/products/components/productVariantsDataGrid/toolbar/ProductVariantsGridToolbar.tsx @@ -0,0 +1,17 @@ +import { Button, DataGridToolbar, FillSpace, GridFilterButton, StackLink } from "@comet/admin"; +import { Add as AddIcon } from "@comet/admin-icons"; +import { GridToolbarQuickFilter } from "@mui/x-data-grid-pro"; +import { FormattedMessage } from "react-intl"; + +export function ProductVariantsGridToolbar() { + return ( + + + + + + + ); +} From 0f35878aaae0e43113ff2495d9dfa5d97c294a30 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 08:57:47 +0100 Subject: [PATCH 5/8] Add ProductVariant edit/create form --- .../validators/validateNonNegativeInteger.tsx | 10 + .../ProductVariantForm.gql.ts | 58 +++++ .../productVariantForm/ProductVariantForm.tsx | 228 ++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 admin/src/common/validators/validateNonNegativeInteger.tsx create mode 100644 admin/src/products/components/productVariantForm/ProductVariantForm.gql.ts create mode 100644 admin/src/products/components/productVariantForm/ProductVariantForm.tsx diff --git a/admin/src/common/validators/validateNonNegativeInteger.tsx b/admin/src/common/validators/validateNonNegativeInteger.tsx new file mode 100644 index 000000000..83ff1694b --- /dev/null +++ b/admin/src/common/validators/validateNonNegativeInteger.tsx @@ -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 ; + } + return undefined; +}; diff --git a/admin/src/products/components/productVariantForm/ProductVariantForm.gql.ts b/admin/src/products/components/productVariantForm/ProductVariantForm.gql.ts new file mode 100644 index 000000000..33cd63ae2 --- /dev/null +++ b/admin/src/products/components/productVariantForm/ProductVariantForm.gql.ts @@ -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} +`; diff --git a/admin/src/products/components/productVariantForm/ProductVariantForm.tsx b/admin/src/products/components/productVariantForm/ProductVariantForm.tsx new file mode 100644 index 000000000..9a71a1df4 --- /dev/null +++ b/admin/src/products/components/productVariantForm/ProductVariantForm.tsx @@ -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; +}; + +interface FormProps { + id?: string; + productId?: string; +} + +export function ProductVariantForm({ id, productId }: FormProps) { + const client = useApolloClient(); + const mode = id ? "edit" : "add"; + const formApiRef = useFormApiRef(); + const stackSwitchApi = useStackSwitchApi(); + + const { data, error, loading, refetch } = useQuery( + productVariantQuery, + id ? { variables: { id } } : { skip: true }, + ); + + const initialValues = useMemo>( + () => + data?.productVariant + ? { + ...filterByFragment(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, 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({ + 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, + ); + } + } else { + if (!productId) throw new Error("productId is required for creating a product variant"); + const { data: mutationResponse } = await client.mutate({ + 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, + ); + } + + const newId = mutationResponse?.createProductVariant.productVariant?.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={validatePositiveNumber} + endAdornment={} + /> + } + validate={validateNonNegativeInteger} + /> + } + variant="horizontal" + fullWidth + /> +
+
}> + } + variant="horizontal" + fullWidth + > + {createFinalFormBlock(rootBlocks.image)} + +
+ + )} + + ); +} From a380a1ae0b4d957a54498c2932bb056e2ea76198 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 08:59:14 +0100 Subject: [PATCH 6/8] Add Variants RouterTab to Product edit page with nested navigation --- admin/src/products/ProductsPage.tsx | 43 ++++++- .../ProductVariantToolbar.gql.ts | 11 ++ .../ProductVariantToolbar.tsx | 108 ++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 admin/src/products/components/productVariantToolbar/ProductVariantToolbar.gql.ts create mode 100644 admin/src/products/components/productVariantToolbar/ProductVariantToolbar.tsx diff --git a/admin/src/products/ProductsPage.tsx b/admin/src/products/ProductsPage.tsx index b611501a3..889ed5666 100644 --- a/admin/src/products/ProductsPage.tsx +++ b/admin/src/products/ProductsPage.tsx @@ -1,6 +1,9 @@ import { Button, FillSpace, + FullHeightContent, + RouterTab, + RouterTabs, SaveBoundary, Stack, StackLink, @@ -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 ( @@ -53,7 +59,42 @@ export const ProductsPage: FunctionComponent = () => { - + + } + > + + + }> + + + + + + + + + + + + + + + + {(variantId) => ( + + + + + + + )} + + + + )} diff --git a/admin/src/products/components/productVariantToolbar/ProductVariantToolbar.gql.ts b/admin/src/products/components/productVariantToolbar/ProductVariantToolbar.gql.ts new file mode 100644 index 000000000..d20e1fb4b --- /dev/null +++ b/admin/src/products/components/productVariantToolbar/ProductVariantToolbar.gql.ts @@ -0,0 +1,11 @@ +import { gql } from "@apollo/client"; + +export const productVariantToolbarQuery = gql` + query ProductVariantToolbar($id: ID!) { + productVariant(id: $id) { + id + name + sku + } + } +`; diff --git a/admin/src/products/components/productVariantToolbar/ProductVariantToolbar.tsx b/admin/src/products/components/productVariantToolbar/ProductVariantToolbar.tsx new file mode 100644 index 000000000..cefad1ac0 --- /dev/null +++ b/admin/src/products/components/productVariantToolbar/ProductVariantToolbar.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 { productVariantToolbarQuery } from "./ProductVariantToolbar.gql"; +import { type GQLProductVariantToolbarQuery, type GQLProductVariantToolbarQueryVariables } from "./ProductVariantToolbar.gql.generated"; + +interface ProductVariantToolbarProps { + id?: string; + additionalActions?: ReactNode; +} + +export const ProductVariantToolbar: FunctionComponent = ({ id, additionalActions }) => { + const theme = useTheme(); + + const { data, loading, error } = useQuery( + productVariantToolbarQuery, + id != null + ? { + variables: { id }, + context: LocalErrorScopeApolloContext, + } + : { skip: true }, + ); + + if (loading) { + return ( + }> + + + + + ); + } + + const title = data?.productVariant.name; + const supportText = data?.productVariant.sku; + + return ( + + }> + + + {title ? ( + + + {title} + {supportText && ( + + {supportText} + + )} + + + ) : ( + + )} + + {error != null && ( + + + + + + + + + + } + > + + + + + + )} + + + + + {additionalActions} + + + + + ); +}; From 7e33b7a83ca7aec12181f8c092a956d5865de867 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 09:00:39 +0100 Subject: [PATCH 7/8] Add variantCount column to Products DataGrid --- .../components/productsDataGrid/ProductsGrid.gql.ts | 1 + .../components/productsDataGrid/ProductsGrid.tsx | 11 ++++++++++- .../VariantStatusSelectField.tsx | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts b/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts index a572c3792..ecf2ac1cb 100644 --- a/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts +++ b/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts @@ -11,6 +11,7 @@ const productsFragment = gql` productStatus publishedAt isPublished + variantCount category { id name diff --git a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx index c70785495..239447a9b 100644 --- a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx +++ b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx @@ -15,7 +15,7 @@ import { } 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 { Box, Chip, 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"; @@ -145,6 +145,15 @@ export function ProductsGrid() { headerName: intl.formatMessage({ id: "product.publishedAt", defaultMessage: "Published At" }), width: 170, }, + { + field: "variantCount", + headerName: intl.formatMessage({ id: "product.variantCount", defaultMessage: "Variants" }), + type: "number", + sortable: false, + filterable: false, + width: 100, + renderCell: ({ value }) => , + }, { field: "isPublished", headerName: intl.formatMessage({ id: "product.isPublished", defaultMessage: "Published" }), diff --git a/admin/src/products/components/variantStatusSelectField/VariantStatusSelectField.tsx b/admin/src/products/components/variantStatusSelectField/VariantStatusSelectField.tsx index d3046b9c4..02bc82708 100644 --- a/admin/src/products/components/variantStatusSelectField/VariantStatusSelectField.tsx +++ b/admin/src/products/components/variantStatusSelectField/VariantStatusSelectField.tsx @@ -4,7 +4,7 @@ import { type GQLVariantStatus } from "@src/graphql.generated"; import { variantStatusFormattedMessageMap } from "@src/products/components/variantStatus/VariantStatus"; import { type FunctionComponent } from "react"; -export type VariantStatusFormState = GQLVariantStatus; +type VariantStatusFormState = GQLVariantStatus; type VariantStatusSelectFieldProps = Omit, "options">; From b45fd58e02aae6cf6d4e721f5a67e14b2fe999e0 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 10:22:40 +0100 Subject: [PATCH 8/8] Fix ScopedEntity to use async loadOrFail for parent scope --- api/src/products/entities/product-variant.entity.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/products/entities/product-variant.entity.ts b/api/src/products/entities/product-variant.entity.ts index b834f7e4e..d65111266 100644 --- a/api/src/products/entities/product-variant.entity.ts +++ b/api/src/products/entities/product-variant.entity.ts @@ -16,10 +16,10 @@ registerEnumType(VariantStatus, { name: "VariantStatus" }); @Entity() @ObjectType() @RootBlockEntity() -@ScopedEntity((productVariant) => ({ - domain: productVariant.product.getEntity().domain, - language: productVariant.product.getEntity().language, -})) +@ScopedEntity(async (productVariant) => { + const product = await productVariant.product.loadOrFail(); + return { domain: product.domain, language: product.language }; +}) export class ProductVariant extends BaseEntity { [OptionalProps]?: "createdAt" | "updatedAt" | "stock" | "isAvailable" | "variantStatus";