From 87856054663ef658ac1fe7ffe3ebe2d79506ebe5 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 15:08:42 +0100 Subject: [PATCH 01/12] add product spec --- .gitignore | 1 + specs/product/01-product.md | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 specs/product/01-product.md 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/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. From e56f7e17744f70a612be30b6e399f9f2cf87a2b4 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 15:47:53 +0100 Subject: [PATCH 02/12] Add Product entity, service, resolver, and DTOs --- api/schema.gql | 160 ++++++++++++++++++ api/src/app.module.ts | 2 + api/src/auth/app-permission.enum.ts | 4 +- .../db/migrations/Migration20260313144308.ts | 12 ++ api/src/products/dto/paginated-products.ts | 6 + api/src/products/dto/product-scope.input.ts | 14 ++ api/src/products/dto/product.filter.ts | 92 ++++++++++ api/src/products/dto/product.input.ts | 62 +++++++ api/src/products/dto/product.sort.ts | 30 ++++ api/src/products/dto/products.args.ts | 31 ++++ api/src/products/entities/product.entity.ts | 93 ++++++++++ api/src/products/product.resolver.ts | 91 ++++++++++ api/src/products/products.module.ts | 12 ++ api/src/products/products.service.ts | 143 ++++++++++++++++ 14 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 api/src/db/migrations/Migration20260313144308.ts create mode 100644 api/src/products/dto/paginated-products.ts create mode 100644 api/src/products/dto/product-scope.input.ts create mode 100644 api/src/products/dto/product.filter.ts create mode 100644 api/src/products/dto/product.input.ts create mode 100644 api/src/products/dto/product.sort.ts create mode 100644 api/src/products/dto/products.args.ts create mode 100644 api/src/products/entities/product.entity.ts create mode 100644 api/src/products/product.resolver.ts create mode 100644 api/src/products/products.module.ts create mode 100644 api/src/products/products.service.ts diff --git a/api/schema.gql b/api/schema.gql index b125ace6f..51e4eefd6 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,120 @@ 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: String! + field: String +} + type Query { blockPreviewJwt(includeInvisible: Boolean!, scope: JSONObject!, url: String!): String! currentUser: CurrentUser! @@ -559,6 +711,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 +858,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..c23a0aafb --- /dev/null +++ b/api/src/products/products.service.ts @@ -0,0 +1,143 @@ +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 } 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"; + +@ObjectType() +export class ProductValidationError { + @Field({ nullable: true }) + field?: string; + + @Field() + code: string; +} + +@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: "SLUG_ALREADY_EXISTS" }); + } + + const existingSku = await this.entityManager.findOne(Product, { sku: input.sku, ...context.scope }); + if (existingSku) { + errors.push({ field: "sku", code: "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: "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: "SKU_ALREADY_EXISTS" }); + } + } + + return errors; + } +} From 0bcd6804518f572bb687fadef02fc560daab6720 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 15:54:26 +0100 Subject: [PATCH 03/12] Add translatable enum components for ProductStatus and ProductType --- .../components/enums/chipIcon/ChipIcon.tsx | 19 ++++++ .../createTranslatableEnum.tsx | 32 ++++++++++ .../components/enums/enumChip/EnumChip.tsx | 50 +++++++++++++++ .../enums/recordToOptions/recordToOptions.ts | 13 ++++ .../productStatus/ProductStatus.tsx | 16 +++++ .../productStatusChip/ProductStatusChip.tsx | 64 +++++++++++++++++++ ...ProductStatusChipEditableForProduct.gql.ts | 21 ++++++ .../ProductStatusChipEditableForProduct.tsx | 56 ++++++++++++++++ .../ProductStatusSelectField.tsx | 13 ++++ .../components/productType/ProductType.tsx | 15 +++++ .../productTypeChip/ProductTypeChip.tsx | 54 ++++++++++++++++ .../ProductTypeChipEditableForProduct.gql.ts | 21 ++++++ .../ProductTypeChipEditableForProduct.tsx | 53 +++++++++++++++ .../ProductTypeSelectField.tsx | 13 ++++ 14 files changed, 440 insertions(+) create mode 100644 admin/src/common/components/enums/chipIcon/ChipIcon.tsx create mode 100644 admin/src/common/components/enums/createTranslatableEnum/createTranslatableEnum.tsx create mode 100644 admin/src/common/components/enums/enumChip/EnumChip.tsx create mode 100644 admin/src/common/components/enums/recordToOptions/recordToOptions.ts create mode 100644 admin/src/products/components/productStatus/ProductStatus.tsx create mode 100644 admin/src/products/components/productStatusChip/ProductStatusChip.tsx create mode 100644 admin/src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct.gql.ts create mode 100644 admin/src/products/components/productStatusChipEditableForProduct/ProductStatusChipEditableForProduct.tsx create mode 100644 admin/src/products/components/productStatusSelectField/ProductStatusSelectField.tsx create mode 100644 admin/src/products/components/productType/ProductType.tsx create mode 100644 admin/src/products/components/productTypeChip/ProductTypeChip.tsx create mode 100644 admin/src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct.gql.ts create mode 100644 admin/src/products/components/productTypeChipEditableForProduct/ProductTypeChipEditableForProduct.tsx create mode 100644 admin/src/products/components/productTypeSelectField/ProductTypeSelectField.tsx 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/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/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..94487ec0a --- /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"; + +export type ProductStatusFormState = GQLProductStatus; + +type ProductStatusSelectFieldProps = Omit, "options">; + +export const ProductStatusSelectField: FunctionComponent = ({ name, ...restProps }) => { + return ; +}; 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..89c7af0d3 --- /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"; + +export type ProductTypeFormState = GQLProductType; + +type ProductTypeSelectFieldProps = Omit, "options">; + +export const ProductTypeSelectField: FunctionComponent = ({ name, ...restProps }) => { + return ; +}; From bfd957c291920179b57a28750e7b93f89bb59dcf Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 16:08:14 +0100 Subject: [PATCH 04/12] Add Products DataGrid with toolbar --- .../messageDescriptorMapToValueOptions.ts | 13 + .../productsDataGrid/ProductsGrid.gql.ts | 33 +++ .../productsDataGrid/ProductsGrid.tsx | 228 ++++++++++++++++++ .../toolbar/ProductsGridToolbar.tsx | 37 +++ 4 files changed, 311 insertions(+) create mode 100644 admin/src/common/components/enums/messageDescriptorMapToValueOptions/messageDescriptorMapToValueOptions.ts create mode 100644 admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts create mode 100644 admin/src/products/components/productsDataGrid/ProductsGrid.tsx create mode 100644 admin/src/products/components/productsDataGrid/toolbar/ProductsGridToolbar.tsx 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/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..5de7a559d --- /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: "product.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: "product.name", defaultMessage: "Name" }), + flex: 1, + minWidth: 200, + }, + { + field: "sku", + headerName: intl.formatMessage({ id: "product.sku", defaultMessage: "SKU" }), + width: 150, + }, + { + field: "productType", + headerName: intl.formatMessage({ id: "product.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: "product.price", defaultMessage: "Price" }), + type: "number", + renderCell: ({ value }) => { + return typeof value === "number" ? ( + + ) : ( + "" + ); + }, + width: 150, + }, + { + field: "productStatus", + headerName: intl.formatMessage({ id: "product.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: "product.publishedAt", defaultMessage: "Published At" }), + width: 170, + }, + { + field: "isPublished", + headerName: intl.formatMessage({ id: "product.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} + + ); +} From 8db5d553a0dd3a0831b592c5d37afe3107956bf0 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 16:08:22 +0100 Subject: [PATCH 05/12] Add Product edit/create form --- .../validators/validatePositiveNumber.tsx | 10 + .../common/validators/validateSkuFormat.tsx | 17 ++ .../components/productForm/ProductForm.gql.ts | 61 ++++ .../components/productForm/ProductForm.tsx | 274 ++++++++++++++++++ 4 files changed, 362 insertions(+) create mode 100644 admin/src/common/validators/validatePositiveNumber.tsx create mode 100644 admin/src/common/validators/validateSkuFormat.tsx create mode 100644 admin/src/products/components/productForm/ProductForm.gql.ts create mode 100644 admin/src/products/components/productForm/ProductForm.tsx 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/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..015961b51 --- /dev/null +++ b/admin/src/products/components/productForm/ProductForm.tsx @@ -0,0 +1,274 @@ +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 { 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} +
}> + } + /> + } + helperText={ + + } + /> + } + /> +
+
}> + } + validate={validateSkuFormat} + helperText={ + + } + /> + } + validate={validatePositiveNumber} + endAdornment={} + /> + } + /> +
+
}> + } + /> + } + /> + } + variant="horizontal" + fullWidth + /> +
+
}> + } + variant="horizontal" + fullWidth + > + {createFinalFormBlock(rootBlocks.mainImage)} + +
+ + )} + + ); +} From abd1df6076b983e5719e1eba827722ffa45facc5 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 16:10:01 +0100 Subject: [PATCH 06/12] Add ProductsPage with Stack navigation --- admin/src/products/ProductsPage.tsx | 37 ++++++ .../productToolbar/ProductToolbar.gql.ts | 11 ++ .../productToolbar/ProductToolbar.tsx | 108 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 admin/src/products/ProductsPage.tsx create mode 100644 admin/src/products/components/productToolbar/ProductToolbar.gql.ts create mode 100644 admin/src/products/components/productToolbar/ProductToolbar.tsx diff --git a/admin/src/products/ProductsPage.tsx b/admin/src/products/ProductsPage.tsx new file mode 100644 index 000000000..620567d29 --- /dev/null +++ b/admin/src/products/ProductsPage.tsx @@ -0,0 +1,37 @@ +import { SaveBoundary, Stack, StackMainContent, StackPage, StackSwitch } from "@comet/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/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} + + + + + ); +}; From 8c0af075da00a55ed5577a993f744a983eebcbbc Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 16:13:05 +0100 Subject: [PATCH 07/12] Add Products to MasterMenu --- admin/src/common/MasterMenu.tsx | 13 ++++++++++++- .../ProductStatusSelectField.tsx | 2 +- .../ProductTypeSelectField.tsx | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) 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/products/components/productStatusSelectField/ProductStatusSelectField.tsx b/admin/src/products/components/productStatusSelectField/ProductStatusSelectField.tsx index 94487ec0a..d33333e4f 100644 --- a/admin/src/products/components/productStatusSelectField/ProductStatusSelectField.tsx +++ b/admin/src/products/components/productStatusSelectField/ProductStatusSelectField.tsx @@ -4,7 +4,7 @@ import { type GQLProductStatus } from "@src/graphql.generated"; import { productStatusFormattedMessageMap } from "@src/products/components/productStatus/ProductStatus"; import { type FunctionComponent } from "react"; -export type ProductStatusFormState = GQLProductStatus; +type ProductStatusFormState = GQLProductStatus; type ProductStatusSelectFieldProps = Omit, "options">; diff --git a/admin/src/products/components/productTypeSelectField/ProductTypeSelectField.tsx b/admin/src/products/components/productTypeSelectField/ProductTypeSelectField.tsx index 89c7af0d3..0ace19ced 100644 --- a/admin/src/products/components/productTypeSelectField/ProductTypeSelectField.tsx +++ b/admin/src/products/components/productTypeSelectField/ProductTypeSelectField.tsx @@ -4,7 +4,7 @@ import { type GQLProductType } from "@src/graphql.generated"; import { productTypeFormattedMessageMap } from "@src/products/components/productType/ProductType"; import { type FunctionComponent } from "react"; -export type ProductTypeFormState = GQLProductType; +type ProductTypeFormState = GQLProductType; type ProductTypeSelectFieldProps = Omit, "options">; From 48115c0cc1d0e8910423d32576c20ab4a2d662f7 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 16:28:34 +0100 Subject: [PATCH 08/12] Add grid page toolbar with scope indicator and Add Product button --- admin/src/products/ProductsPage.tsx | 31 +++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/admin/src/products/ProductsPage.tsx b/admin/src/products/ProductsPage.tsx index 620567d29..b611501a3 100644 --- a/admin/src/products/ProductsPage.tsx +++ b/admin/src/products/ProductsPage.tsx @@ -1,4 +1,19 @@ -import { SaveBoundary, Stack, StackMainContent, StackPage, StackSwitch } from "@comet/admin"; +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"; @@ -11,7 +26,19 @@ export const ProductsPage: FunctionComponent = () => { }> - + }> + + + + + + + + + + From 3ddfe7dd6406e052ecd26142a787329297b50104 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 16:30:51 +0100 Subject: [PATCH 09/12] Fix formatting in ProductForm mainImage handling --- admin/src/products/components/productForm/ProductForm.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/admin/src/products/components/productForm/ProductForm.tsx b/admin/src/products/components/productForm/ProductForm.tsx index 015961b51..8591a91c4 100644 --- a/admin/src/products/components/productForm/ProductForm.tsx +++ b/admin/src/products/components/productForm/ProductForm.tsx @@ -82,7 +82,9 @@ export function ProductForm({ id }: FormProps) { ? { ...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(), + mainImage: data.product.mainImage + ? rootBlocks.mainImage.input2State(data.product.mainImage) + : rootBlocks.mainImage.defaultValues(), } : { isPublished: false, From 2c1d739d79bbd74c7fb89354ffdd5d9a1781c364 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 06:59:31 +0100 Subject: [PATCH 10/12] Use typed ProductValidationErrorCode enum for validation errors Replace string literals with a registered GraphQL enum for product validation error codes in both API and admin. --- .../components/productForm/ProductForm.tsx | 3 ++- api/schema.gql | 7 ++++++- api/src/products/products.service.ts | 21 ++++++++++++------- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/admin/src/products/components/productForm/ProductForm.tsx b/admin/src/products/components/productForm/ProductForm.tsx index 8591a91c4..34a394265 100644 --- a/admin/src/products/components/productForm/ProductForm.tsx +++ b/admin/src/products/components/productForm/ProductForm.tsx @@ -26,6 +26,7 @@ import { import { InputAdornment } from "@mui/material"; import { validatePositiveNumber } from "@src/common/validators/validatePositiveNumber"; import { validateSkuFormat } from "@src/common/validators/validateSkuFormat"; +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"; @@ -55,7 +56,7 @@ type ProductFormDetailsFragment = Omit = { +const submissionErrorMessages: Record = { SLUG_ALREADY_EXISTS: , SKU_ALREADY_EXISTS: , }; diff --git a/api/schema.gql b/api/schema.gql index 51e4eefd6..1207d12ee 100644 --- a/api/schema.gql +++ b/api/schema.gql @@ -681,10 +681,15 @@ input ProductUpdateInput { } type ProductValidationError { - code: String! + code: ProductValidationErrorCode! field: String } +enum ProductValidationErrorCode { + SKU_ALREADY_EXISTS + SLUG_ALREADY_EXISTS +} + type Query { blockPreviewJwt(includeInvisible: Boolean!, scope: JSONObject!, url: String!): String! currentUser: CurrentUser! diff --git a/api/src/products/products.service.ts b/api/src/products/products.service.ts index c23a0aafb..40a4827fd 100644 --- a/api/src/products/products.service.ts +++ b/api/src/products/products.service.ts @@ -1,7 +1,7 @@ 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 } from "@nestjs/graphql"; +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"; @@ -9,13 +9,20 @@ 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() - code: string; + @Field(() => ProductValidationErrorCode) + code: ProductValidationErrorCode; } @Injectable() @@ -97,12 +104,12 @@ export class ProductsService { const existingSlug = await this.entityManager.findOne(Product, { slug: input.slug, ...context.scope }); if (existingSlug) { - errors.push({ field: "slug", code: "SLUG_ALREADY_EXISTS" }); + 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: "SKU_ALREADY_EXISTS" }); + errors.push({ field: "sku", code: ProductValidationErrorCode.SKU_ALREADY_EXISTS }); } return errors; @@ -122,7 +129,7 @@ export class ProductsService { id: { $ne: context.entity.id }, }); if (existingSlug) { - errors.push({ field: "slug", code: "SLUG_ALREADY_EXISTS" }); + errors.push({ field: "slug", code: ProductValidationErrorCode.SLUG_ALREADY_EXISTS }); } } @@ -134,7 +141,7 @@ export class ProductsService { id: { $ne: context.entity.id }, }); if (existingSku) { - errors.push({ field: "sku", code: "SKU_ALREADY_EXISTS" }); + errors.push({ field: "sku", code: ProductValidationErrorCode.SKU_ALREADY_EXISTS }); } } From c9f154872bfb039323d848ca87e9373e74635657 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:11:19 +0100 Subject: [PATCH 11/12] Add client-side slug validation to prevent invalid slug submissions --- admin/src/common/validators/validateSlug.tsx | 17 +++++++++++++++++ .../components/productForm/ProductForm.tsx | 2 ++ 2 files changed, 19 insertions(+) create mode 100644 admin/src/common/validators/validateSlug.tsx 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/components/productForm/ProductForm.tsx b/admin/src/products/components/productForm/ProductForm.tsx index 34a394265..1557fe959 100644 --- a/admin/src/products/components/productForm/ProductForm.tsx +++ b/admin/src/products/components/productForm/ProductForm.tsx @@ -26,6 +26,7 @@ import { 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"; @@ -192,6 +193,7 @@ export function ProductForm({ id }: FormProps) { fullWidth name="slug" label={} + validate={validateSlug} helperText={ Date: Mon, 16 Mar 2026 13:50:00 +0100 Subject: [PATCH 12/12] fix different default messages for same id --- .../productsDataGrid/ProductsGrid.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx index 5de7a559d..7df68227c 100644 --- a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx +++ b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx @@ -55,7 +55,7 @@ export function ProductsGrid() { () => [ { field: "mainImage", - headerName: intl.formatMessage({ id: "product.mainImage", defaultMessage: "Image" }), + headerName: intl.formatMessage({ id: "productsGrid.mainImage", defaultMessage: "Image" }), sortable: false, filterable: false, disableExport: true, @@ -76,18 +76,18 @@ export function ProductsGrid() { }, { field: "name", - headerName: intl.formatMessage({ id: "product.name", defaultMessage: "Name" }), + headerName: intl.formatMessage({ id: "productsGrid.name", defaultMessage: "Name" }), flex: 1, minWidth: 200, }, { field: "sku", - headerName: intl.formatMessage({ id: "product.sku", defaultMessage: "SKU" }), + headerName: intl.formatMessage({ id: "productsGrid.sku", defaultMessage: "SKU" }), width: 150, }, { field: "productType", - headerName: intl.formatMessage({ id: "product.productType", defaultMessage: "Type" }), + headerName: intl.formatMessage({ id: "productsGrid.productType", defaultMessage: "Type" }), type: "singleSelect", valueOptions: messageDescriptorMapToValueOptions(productTypeMessageDescriptorMap, intl), width: 160, @@ -101,13 +101,13 @@ export function ProductsGrid() { field: "price", renderHeader: () => ( <> - - }> + + }> ), - headerName: intl.formatMessage({ id: "product.price", defaultMessage: "Price" }), + headerName: intl.formatMessage({ id: "productsGrid.price", defaultMessage: "Price" }), type: "number", renderCell: ({ value }) => { return typeof value === "number" ? ( @@ -120,7 +120,7 @@ export function ProductsGrid() { }, { field: "productStatus", - headerName: intl.formatMessage({ id: "product.productStatus", defaultMessage: "Status" }), + headerName: intl.formatMessage({ id: "productsGrid.productStatus", defaultMessage: "Status" }), type: "singleSelect", valueOptions: messageDescriptorMapToValueOptions(productStatusMessageDescriptorMap, intl), width: 160, @@ -133,12 +133,12 @@ export function ProductsGrid() { { ...dataGridDateTimeColumn, field: "publishedAt", - headerName: intl.formatMessage({ id: "product.publishedAt", defaultMessage: "Published At" }), + headerName: intl.formatMessage({ id: "productsGrid.publishedAt", defaultMessage: "Published At" }), width: 170, }, { field: "isPublished", - headerName: intl.formatMessage({ id: "product.isPublished", defaultMessage: "Published" }), + headerName: intl.formatMessage({ id: "productsGrid.isPublished", defaultMessage: "Published" }), type: "boolean", width: 100, },