From 7c1f40842597e57e10c96b2b51e8521351ef75b1 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:29:17 +0100 Subject: [PATCH 01/10] Add ProductCategory spec and update Product spec with optional category relation --- specs/product/01-product.md | 6 +++- specs/product/02-product-category.md | 49 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 specs/product/02-product-category.md diff --git a/specs/product/01-product.md b/specs/product/01-product.md index 0c5113437..6b3e84712 100644 --- a/specs/product/01-product.md +++ b/specs/product/01-product.md @@ -17,6 +17,10 @@ The Product entity represents a product in the catalog. Products are scoped by d - isPublished: boolean, default false - mainImage: DAM image, optional +## Relations + +- category: ManyToOne to ProductCategory, optional (a product can optionally belong to one category) + ## Enums - productStatus: Draft, InReview, Published, Archived @@ -49,7 +53,7 @@ The grid should support search by name and sku, filtering by productStatus and p All fields in a single form. Group into FieldSets: - "General": name, slug, description -- "Details": sku, price, productType (SelectField) +- "Details": sku, price, productType (SelectField), category (AsyncSelectField) - "Publishing": productStatus (SelectField), publishedAt, isPublished - "Media": mainImage diff --git a/specs/product/02-product-category.md b/specs/product/02-product-category.md new file mode 100644 index 000000000..438821815 --- /dev/null +++ b/specs/product/02-product-category.md @@ -0,0 +1,49 @@ +--- +title: "02 - Product Category" +--- + +# Product Category + +The ProductCategory entity represents a hierarchical category for organizing products. Categories can be nested via a self-referencing parent relation and are manually ordered. + +## Fields + +- name: string, required +- slug: string, required, unique per scope +- position: number (for manual ordering) + +## Relations + +- parentCategory: ManyToOne to ProductCategory, optional (top-level categories have no parent) +- products: OneToMany from Product (a product can optionally belong to one category) + +## Requirements + +- The entity is scoped (domain + language). +- Categories are ordered by position (drag-and-drop reordering in the grid). +- Categories can be nested: a category can have a parent category. + +## DataGrid + +Non-paginated grid (all categories loaded at once) with drag-and-drop row reordering. + +Columns: name, slug, parentCategory (show parent name). + +No search or filter — the dataset is small enough to display in full. + +## Form + +All fields. Group into one FieldSet: + +- "General": name, slug, parentCategory (AsyncSelectField) + +## Pages + +Grid with edit page. The entity toolbar shows the category name. + +## Acceptance Criteria + +- Categories can be created, edited, deleted, and reordered via drag-and-drop. +- A category can optionally reference a parent category. +- Position ordering persists across page reloads. +- Slug uniqueness is enforced within the scope. From c5d98e30e3e56ec0c792d3f005b6b21a4c92a77e Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:39:44 +0100 Subject: [PATCH 02/10] Add ProductCategory entity, service, resolver, and DTOs --- api/schema.gql | 94 ++++++++ api/src/app.module.ts | 2 + api/src/auth/app-permission.enum.ts | 1 + .../db/migrations/Migration20260316063858.ts | 15 ++ .../dto/paginated-product-categories.ts | 6 + .../dto/product-categories.args.ts | 31 +++ .../dto/product-category-scope.input.ts | 14 ++ .../dto/product-category.filter.ts | 61 ++++++ .../dto/product-category.input.ts | 30 +++ .../dto/product-category.sort.ts | 25 +++ .../entities/product-category.entity.ts | 50 +++++ .../product-categories.module.ts | 13 ++ .../product-categories.service.ts | 207 ++++++++++++++++++ .../product-category.resolver.ts | 90 ++++++++ 14 files changed, 639 insertions(+) create mode 100644 api/src/db/migrations/Migration20260316063858.ts create mode 100644 api/src/product-categories/dto/paginated-product-categories.ts create mode 100644 api/src/product-categories/dto/product-categories.args.ts create mode 100644 api/src/product-categories/dto/product-category-scope.input.ts create mode 100644 api/src/product-categories/dto/product-category.filter.ts create mode 100644 api/src/product-categories/dto/product-category.input.ts create mode 100644 api/src/product-categories/dto/product-category.sort.ts create mode 100644 api/src/product-categories/entities/product-category.entity.ts create mode 100644 api/src/product-categories/product-categories.module.ts create mode 100644 api/src/product-categories/product-categories.service.ts create mode 100644 api/src/product-categories/product-category.resolver.ts diff --git a/api/schema.gql b/api/schema.gql index 1207d12ee..9f5e49a42 100644 --- a/api/schema.gql +++ b/api/schema.gql @@ -26,6 +26,11 @@ input CreateDamFolderInput { parentId: ID } +type CreateProductCategoryPayload { + errors: [ProductCategoryValidationError!]! + productCategory: ProductCategory +} + type CreateProductPayload { errors: [ProductValidationError!]! product: Product @@ -335,6 +340,12 @@ input LinkInput { content: LinkBlockInput! } +input ManyToOneFilter { + equal: ID + isAnyOf: [ID!] + notEqual: ID +} + type MappedFile { copy: DamFile! rootFile: DamFile! @@ -359,6 +370,7 @@ type Mutation { createDamMediaAlternative(alternative: ID!, for: ID!, input: DamMediaAlternativeInput!): DamMediaAlternative! createPageTreeNode(category: String!, input: PageTreeNodeCreateInput!, scope: PageTreeNodeScopeInput!): PageTreeNode! createProduct(input: ProductInput!, scope: ProductScopeInput!): CreateProductPayload! + createProductCategory(input: ProductCategoryInput!, scope: ProductCategoryScopeInput!): CreateProductCategoryPayload! createRedirect(input: RedirectInput!, scope: RedirectScopeInput!): Redirect! currentUserSignOut: String! deleteDamFile(id: ID!): Boolean! @@ -366,6 +378,7 @@ type Mutation { deleteDamMediaAlternative(id: ID!): Boolean! deletePageTreeNode(id: ID!): Boolean! deleteProduct(id: ID!): Boolean! + deleteProductCategory(id: ID!): Boolean! deleteRedirect(id: ID!): Boolean! importDamFileByDownload(input: UpdateDamFileInput!, scope: DamScopeInput! = {}, url: String!): DamFile! moveDamFiles(fileIds: [ID!]!, targetFolderId: ID): [DamFile!]! @@ -385,6 +398,7 @@ type Mutation { updatePageTreeNodeSlug(id: ID!, slug: String!): PageTreeNode! updatePageTreeNodeVisibility(id: ID!, input: PageTreeNodeUpdateVisibilityInput!): PageTreeNode! updateProduct(id: ID!, input: ProductUpdateInput!): UpdateProductPayload! + updateProductCategory(id: ID!, input: ProductCategoryUpdateInput!): UpdateProductCategoryPayload! updateRedirect(id: ID!, input: RedirectInput!, lastUpdatedAt: DateTime): Redirect! updateRedirectActiveness(id: ID!, input: RedirectUpdateActivenessInput!): Redirect! userPermissionsCreatePermission(input: UserPermissionInput!, userId: String!): UserPermission! @@ -533,6 +547,11 @@ type PaginatedPageTreeNodes { totalCount: Int! } +type PaginatedProductCategories { + nodes: [ProductCategory!]! + totalCount: Int! +} + type PaginatedProducts { nodes: [Product!]! totalCount: Int! @@ -558,6 +577,7 @@ enum Permission { impersonation pageTree prelogin + productCategories products sitePreview translation @@ -589,6 +609,72 @@ type Product { updatedAt: DateTime! } +type ProductCategory { + createdAt: DateTime! + domain: String! + id: ID! + language: String! + name: String! + parentCategory: ProductCategory + position: Int! + slug: String! + updatedAt: DateTime! +} + +input ProductCategoryFilter { + and: [ProductCategoryFilter!] + createdAt: DateTimeFilter + id: IdFilter + name: StringFilter + or: [ProductCategoryFilter!] + parentCategory: ManyToOneFilter + position: NumberFilter + slug: StringFilter + updatedAt: DateTimeFilter +} + +input ProductCategoryInput { + name: String! + parentCategory: ID + position: Int + slug: String! +} + +input ProductCategoryScopeInput { + domain: String! + language: String! +} + +input ProductCategorySort { + direction: SortDirection! = ASC + field: ProductCategorySortField! +} + +enum ProductCategorySortField { + createdAt + id + name + position + slug + updatedAt +} + +input ProductCategoryUpdateInput { + name: String + parentCategory: ID + position: Int + slug: String +} + +type ProductCategoryValidationError { + code: ProductCategoryValidationErrorCode! + field: String +} + +enum ProductCategoryValidationErrorCode { + SLUG_ALREADY_EXISTS +} + input ProductFilter { and: [ProductFilter!] createdAt: DateTimeFilter @@ -718,6 +804,9 @@ type Query { paginatedRedirects(filter: RedirectFilter, limit: Int! = 25, offset: Int! = 0, scope: RedirectScopeInput!, search: String, sort: [RedirectSort!]): PaginatedRedirects! product(id: ID!): Product! productBySlug(scope: ProductScopeInput!, slug: String!): Product + productCategories(filter: ProductCategoryFilter, limit: Int! = 25, offset: Int! = 0, scope: ProductCategoryScopeInput!, search: String, sort: [ProductCategorySort!]! = [{direction: ASC, field: position}]): PaginatedProductCategories! + productCategory(id: ID!): ProductCategory! + productCategoryBySlug(scope: ProductCategoryScopeInput!, slug: String!): ProductCategory products(filter: ProductFilter, limit: Int! = 25, offset: Int! = 0, scope: ProductScopeInput!, search: String, sort: [ProductSort!]! = [{direction: ASC, field: createdAt}]): PaginatedProducts! redirect(id: ID!): Redirect! redirectBySource(scope: RedirectScopeInput!, source: String!, sourceType: RedirectSourceTypeValues!): Redirect @@ -863,6 +952,11 @@ input UpdateImageFileInput { cropArea: ImageCropAreaInput } +type UpdateProductCategoryPayload { + errors: [ProductCategoryValidationError!]! + productCategory: ProductCategory +} + type UpdateProductPayload { errors: [ProductValidationError!]! product: Product diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 474a6d597..f76e8ea7b 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -36,6 +36,7 @@ import { ConfigModule } from "./config/config.module"; import { DamFile } from "./dam/entities/dam-file.entity"; import { DamFolder } from "./dam/entities/dam-folder.entity"; import { MenusModule } from "./menus/menus.module"; +import { ProductCategoriesModule } from "./product-categories/product-categories.module"; import { ProductsModule } from "./products/products.module"; import { StatusModule } from "./status/status.module"; @@ -135,6 +136,7 @@ export class AppModule { MenusModule, DependenciesModule, FootersModule, + ProductCategoriesModule, ProductsModule, WarningsModule, ...(!config.debug diff --git a/api/src/auth/app-permission.enum.ts b/api/src/auth/app-permission.enum.ts index 314073697..47c77c6c2 100644 --- a/api/src/auth/app-permission.enum.ts +++ b/api/src/auth/app-permission.enum.ts @@ -1,3 +1,4 @@ export enum AppPermission { + productCategories = "productCategories", products = "products", } diff --git a/api/src/db/migrations/Migration20260316063858.ts b/api/src/db/migrations/Migration20260316063858.ts new file mode 100644 index 000000000..0b09b7230 --- /dev/null +++ b/api/src/db/migrations/Migration20260316063858.ts @@ -0,0 +1,15 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260316063858 extends Migration { + + override async up(): Promise { + this.addSql(`create table "ProductCategory" ("id" uuid not null, "name" text not null, "slug" text not null, "position" int not null default 0, "parentCategory" uuid null, "domain" text not null, "language" text not null, "createdAt" timestamptz not null, "updatedAt" timestamptz not null, constraint "ProductCategory_pkey" primary key ("id"));`); + + this.addSql(`alter table "ProductCategory" add constraint "ProductCategory_parentCategory_foreign" foreign key ("parentCategory") references "ProductCategory" ("id") on update cascade on delete set null;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "ProductCategory" cascade;`); + } + +} diff --git a/api/src/product-categories/dto/paginated-product-categories.ts b/api/src/product-categories/dto/paginated-product-categories.ts new file mode 100644 index 000000000..21e4a2262 --- /dev/null +++ b/api/src/product-categories/dto/paginated-product-categories.ts @@ -0,0 +1,6 @@ +import { PaginatedResponseFactory } from "@comet/cms-api"; +import { ObjectType } from "@nestjs/graphql"; +import { ProductCategory } from "@src/product-categories/entities/product-category.entity"; + +@ObjectType() +export class PaginatedProductCategories extends PaginatedResponseFactory.create(ProductCategory) {} diff --git a/api/src/product-categories/dto/product-categories.args.ts b/api/src/product-categories/dto/product-categories.args.ts new file mode 100644 index 000000000..aa3d6bef0 --- /dev/null +++ b/api/src/product-categories/dto/product-categories.args.ts @@ -0,0 +1,31 @@ +import { OffsetBasedPaginationArgs, SortDirection } from "@comet/cms-api"; +import { ArgsType, Field } from "@nestjs/graphql"; +import { ProductCategoryFilter } from "@src/product-categories/dto/product-category.filter"; +import { ProductCategorySort, ProductCategorySortField } from "@src/product-categories/dto/product-category.sort"; +import { ProductCategoryScope } from "@src/product-categories/dto/product-category-scope.input"; +import { Type } from "class-transformer"; +import { IsOptional, IsString, ValidateNested } from "class-validator"; + +@ArgsType() +export class ProductCategoriesArgs extends OffsetBasedPaginationArgs { + @Field(() => ProductCategoryScope) + @ValidateNested() + @Type(() => ProductCategoryScope) + scope: ProductCategoryScope; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + search?: string; + + @Field(() => ProductCategoryFilter, { nullable: true }) + @ValidateNested() + @Type(() => ProductCategoryFilter) + @IsOptional() + filter?: ProductCategoryFilter; + + @Field(() => [ProductCategorySort], { defaultValue: [{ field: ProductCategorySortField.position, direction: SortDirection.ASC }] }) + @ValidateNested({ each: true }) + @Type(() => ProductCategorySort) + sort: ProductCategorySort[]; +} diff --git a/api/src/product-categories/dto/product-category-scope.input.ts b/api/src/product-categories/dto/product-category-scope.input.ts new file mode 100644 index 000000000..184347b66 --- /dev/null +++ b/api/src/product-categories/dto/product-category-scope.input.ts @@ -0,0 +1,14 @@ +import { Field, InputType, ObjectType } from "@nestjs/graphql"; +import { IsString } from "class-validator"; + +@ObjectType() +@InputType("ProductCategoryScopeInput") +export class ProductCategoryScope { + @Field() + @IsString() + domain: string; + + @Field() + @IsString() + language: string; +} diff --git a/api/src/product-categories/dto/product-category.filter.ts b/api/src/product-categories/dto/product-category.filter.ts new file mode 100644 index 000000000..38cee07ff --- /dev/null +++ b/api/src/product-categories/dto/product-category.filter.ts @@ -0,0 +1,61 @@ +import { DateTimeFilter, IdFilter, ManyToOneFilter, NumberFilter, StringFilter } from "@comet/cms-api"; +import { Field, InputType } from "@nestjs/graphql"; +import { Type } from "class-transformer"; +import { IsOptional, ValidateNested } from "class-validator"; + +@InputType() +export class ProductCategoryFilter { + @Field(() => IdFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => IdFilter) + id?: IdFilter; + + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => StringFilter) + name?: StringFilter; + + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => StringFilter) + slug?: StringFilter; + + @Field(() => NumberFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => NumberFilter) + position?: NumberFilter; + + @Field(() => ManyToOneFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => ManyToOneFilter) + parentCategory?: ManyToOneFilter; + + @Field(() => DateTimeFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => DateTimeFilter) + createdAt?: DateTimeFilter; + + @Field(() => DateTimeFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => DateTimeFilter) + updatedAt?: DateTimeFilter; + + @Field(() => [ProductCategoryFilter], { nullable: true }) + @Type(() => ProductCategoryFilter) + @ValidateNested({ each: true }) + @IsOptional() + and?: ProductCategoryFilter[]; + + @Field(() => [ProductCategoryFilter], { nullable: true }) + @Type(() => ProductCategoryFilter) + @ValidateNested({ each: true }) + @IsOptional() + or?: ProductCategoryFilter[]; +} diff --git a/api/src/product-categories/dto/product-category.input.ts b/api/src/product-categories/dto/product-category.input.ts new file mode 100644 index 000000000..820171881 --- /dev/null +++ b/api/src/product-categories/dto/product-category.input.ts @@ -0,0 +1,30 @@ +import { IsSlug, PartialType } from "@comet/cms-api"; +import { Field, ID, InputType, Int } from "@nestjs/graphql"; +import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from "class-validator"; + +@InputType() +export class ProductCategoryInput { + @IsNotEmpty() + @IsString() + @Field() + name: string; + + @IsNotEmpty() + @IsSlug() + @Field() + slug: string; + + @IsOptional() + @Min(1) + @IsInt() + @Field(() => Int, { nullable: true }) + position?: number; + + @IsOptional() + @IsUUID() + @Field(() => ID, { nullable: true }) + parentCategory?: string; +} + +@InputType() +export class ProductCategoryUpdateInput extends PartialType(ProductCategoryInput) {} diff --git a/api/src/product-categories/dto/product-category.sort.ts b/api/src/product-categories/dto/product-category.sort.ts new file mode 100644 index 000000000..0965d747d --- /dev/null +++ b/api/src/product-categories/dto/product-category.sort.ts @@ -0,0 +1,25 @@ +import { SortDirection } from "@comet/cms-api"; +import { Field, InputType, registerEnumType } from "@nestjs/graphql"; +import { IsEnum } from "class-validator"; + +export enum ProductCategorySortField { + name = "name", + slug = "slug", + position = "position", + createdAt = "createdAt", + updatedAt = "updatedAt", + id = "id", +} + +registerEnumType(ProductCategorySortField, { name: "ProductCategorySortField" }); + +@InputType() +export class ProductCategorySort { + @Field(() => ProductCategorySortField) + @IsEnum(ProductCategorySortField) + field: ProductCategorySortField; + + @Field(() => SortDirection, { defaultValue: SortDirection.ASC }) + @IsEnum(SortDirection) + direction: SortDirection = SortDirection.ASC; +} diff --git a/api/src/product-categories/entities/product-category.entity.ts b/api/src/product-categories/entities/product-category.entity.ts new file mode 100644 index 000000000..05d7f8d05 --- /dev/null +++ b/api/src/product-categories/entities/product-category.entity.ts @@ -0,0 +1,50 @@ +import { ScopedEntity } from "@comet/cms-api"; +import { BaseEntity, Entity, ManyToOne, OptionalProps, PrimaryKey, Property, Ref } from "@mikro-orm/postgresql"; +import { Field, ID, Int, ObjectType } from "@nestjs/graphql"; +import { v4 as uuid } from "uuid"; + +@Entity() +@ObjectType() +@ScopedEntity((productCategory) => ({ + domain: productCategory.domain, + language: productCategory.language, +})) +export class ProductCategory extends BaseEntity { + [OptionalProps]?: "createdAt" | "updatedAt" | "position"; + + @PrimaryKey({ type: "uuid" }) + @Field(() => ID) + id: string = uuid(); + + @Property({ type: "text" }) + @Field() + name: string; + + @Property({ type: "text" }) + @Field() + slug: string; + + @Property({ type: "integer" }) + @Field(() => Int) + position: number = 0; + + @ManyToOne(() => ProductCategory, { ref: true, nullable: true }) + @Field(() => ProductCategory, { nullable: true }) + parentCategory?: Ref; + + @Property({ type: "text" }) + @Field() + domain: string; + + @Property({ type: "text" }) + @Field() + language: string; + + @Property({ type: "timestamp with time zone" }) + @Field() + createdAt: Date = new Date(); + + @Property({ type: "timestamp with time zone", onUpdate: () => new Date() }) + @Field() + updatedAt: Date = new Date(); +} diff --git a/api/src/product-categories/product-categories.module.ts b/api/src/product-categories/product-categories.module.ts new file mode 100644 index 000000000..d41c339a5 --- /dev/null +++ b/api/src/product-categories/product-categories.module.ts @@ -0,0 +1,13 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { Module } from "@nestjs/common"; + +import { ProductCategory } from "./entities/product-category.entity"; +import { ProductCategoriesService } from "./product-categories.service"; +import { ProductCategoryResolver } from "./product-category.resolver"; + +@Module({ + imports: [MikroOrmModule.forFeature([ProductCategory])], + providers: [ProductCategoriesService, ProductCategoryResolver], + exports: [ProductCategoriesService], +}) +export class ProductCategoriesModule {} diff --git a/api/src/product-categories/product-categories.service.ts b/api/src/product-categories/product-categories.service.ts new file mode 100644 index 000000000..7a3c95d09 --- /dev/null +++ b/api/src/product-categories/product-categories.service.ts @@ -0,0 +1,207 @@ +import { CurrentUser, gqlArgsToMikroOrmQuery, gqlSortToMikroOrmOrderBy } from "@comet/cms-api"; +import { EntityManager, FilterQuery, FindOptions, raw, Reference } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; +import { Field, ObjectType, registerEnumType } from "@nestjs/graphql"; +import { ProductCategoriesArgs } from "@src/product-categories/dto/product-categories.args"; +import { ProductCategoryInput, ProductCategoryUpdateInput } from "@src/product-categories/dto/product-category.input"; +import { ProductCategoryScope } from "@src/product-categories/dto/product-category-scope.input"; +import { ProductCategory } from "@src/product-categories/entities/product-category.entity"; + +import { PaginatedProductCategories } from "./dto/paginated-product-categories"; + +enum ProductCategoryValidationErrorCode { + SLUG_ALREADY_EXISTS = "SLUG_ALREADY_EXISTS", +} + +registerEnumType(ProductCategoryValidationErrorCode, { name: "ProductCategoryValidationErrorCode" }); + +@ObjectType() +export class ProductCategoryValidationError { + @Field({ nullable: true }) + field?: string; + + @Field(() => ProductCategoryValidationErrorCode) + code: ProductCategoryValidationErrorCode; +} + +@Injectable() +export class ProductCategoriesService { + constructor(private readonly entityManager: EntityManager) {} + + async findOneById(id: string): Promise { + return this.entityManager.findOneOrFail(ProductCategory, id); + } + + async findAll({ scope, search, filter, sort, offset, limit }: ProductCategoriesArgs, fields?: string[]): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(ProductCategory)); + Object.assign(where, scope); + const options: FindOptions = { offset, limit }; + if (sort) { + options.orderBy = gqlSortToMikroOrmOrderBy(sort); + } + const populate: string[] = []; + if (fields?.includes("parentCategory")) { + populate.push("parentCategory"); + } + if (populate.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options.populate = populate as any; + } + const [entities, totalCount] = await this.entityManager.findAndCount(ProductCategory, where, options); + return new PaginatedProductCategories(entities, totalCount); + } + + async findBySlug(scope: ProductCategoryScope, slug: string): Promise { + const productCategory = await this.entityManager.findOne(ProductCategory, { slug, ...scope }); + return productCategory ?? null; + } + + async create( + scope: ProductCategoryScope, + input: ProductCategoryInput, + user: CurrentUser, + ): Promise<{ productCategory?: ProductCategory; errors: ProductCategoryValidationError[] }> { + const errors = await this.validateCreateInput(input, { currentUser: user, scope }); + if (errors.length > 0) { + return { errors }; + } + + const { parentCategory: parentCategoryInput, ...assignInput } = input; + const group = { domain: scope.domain, language: scope.language }; + const lastPosition = await this.getLastPosition(group); + let position = assignInput.position; + if (position !== undefined && position < lastPosition + 1) { + await this.incrementPositions(group, position); + } else { + position = lastPosition + 1; + } + + const productCategory = this.entityManager.create(ProductCategory, { + ...assignInput, + ...scope, + position, + ...(parentCategoryInput + ? { parentCategory: Reference.create(await this.entityManager.findOneOrFail(ProductCategory, parentCategoryInput)) } + : {}), + }); + await this.entityManager.flush(); + return { productCategory, errors: [] }; + } + + async update( + id: string, + input: ProductCategoryUpdateInput, + user: CurrentUser, + ): Promise<{ productCategory?: ProductCategory; errors: ProductCategoryValidationError[] }> { + const productCategory = await this.entityManager.findOneOrFail(ProductCategory, id); + + const errors = await this.validateUpdateInput(input, { currentUser: user, entity: productCategory }); + if (errors.length > 0) { + return { errors }; + } + + const { parentCategory: parentCategoryInput, ...assignInput } = input; + const group = { domain: productCategory.domain, language: productCategory.language }; + + if (assignInput.position !== undefined) { + const lastPosition = await this.getLastPosition(group); + if (assignInput.position > lastPosition) { + assignInput.position = lastPosition; + } + if (productCategory.position < assignInput.position) { + await this.decrementPositions(group, productCategory.position, assignInput.position); + } else if (productCategory.position > assignInput.position) { + await this.incrementPositions(group, assignInput.position, productCategory.position); + } + } + + productCategory.assign({ ...assignInput }); + + if (parentCategoryInput !== undefined) { + productCategory.parentCategory = parentCategoryInput + ? Reference.create(await this.entityManager.findOneOrFail(ProductCategory, parentCategoryInput)) + : undefined; + } + + await this.entityManager.flush(); + return { productCategory, errors: [] }; + } + + async delete(id: string): Promise { + const productCategory = await this.entityManager.findOneOrFail(ProductCategory, id); + const group = { domain: productCategory.domain, language: productCategory.language }; + this.entityManager.remove(productCategory); + await this.decrementPositions(group, productCategory.position); + await this.entityManager.flush(); + return true; + } + + private async incrementPositions(group: { domain: string; language: string }, lowestPosition: number, highestPosition?: number) { + await this.entityManager.nativeUpdate( + ProductCategory, + { + $and: [ + { position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } }, + this.getPositionGroupCondition(group), + ], + }, + { position: raw("position + 1") }, + ); + } + + private async decrementPositions(group: { domain: string; language: string }, lowestPosition: number, highestPosition?: number) { + await this.entityManager.nativeUpdate( + ProductCategory, + { + $and: [ + { position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } }, + this.getPositionGroupCondition(group), + ], + }, + { position: raw("position - 1") }, + ); + } + + private async getLastPosition(group: { domain: string; language: string }) { + return this.entityManager.count(ProductCategory, this.getPositionGroupCondition(group)); + } + + private getPositionGroupCondition(group: { domain: string; language: string }): FilterQuery { + return { domain: group.domain, language: group.language }; + } + + private async validateCreateInput( + input: ProductCategoryInput, + context: { currentUser: CurrentUser; scope: ProductCategoryScope }, + ): Promise { + const errors: ProductCategoryValidationError[] = []; + + const existingSlug = await this.entityManager.findOne(ProductCategory, { slug: input.slug, ...context.scope }); + if (existingSlug) { + errors.push({ field: "slug", code: ProductCategoryValidationErrorCode.SLUG_ALREADY_EXISTS }); + } + + return errors; + } + + private async validateUpdateInput( + input: ProductCategoryUpdateInput, + context: { currentUser: CurrentUser; entity: ProductCategory }, + ): Promise { + const errors: ProductCategoryValidationError[] = []; + + if (input.slug !== undefined) { + const existingSlug = await this.entityManager.findOne(ProductCategory, { + slug: input.slug, + domain: context.entity.domain, + language: context.entity.language, + id: { $ne: context.entity.id }, + }); + if (existingSlug) { + errors.push({ field: "slug", code: ProductCategoryValidationErrorCode.SLUG_ALREADY_EXISTS }); + } + } + + return errors; + } +} diff --git a/api/src/product-categories/product-category.resolver.ts b/api/src/product-categories/product-category.resolver.ts new file mode 100644 index 000000000..8365dace0 --- /dev/null +++ b/api/src/product-categories/product-category.resolver.ts @@ -0,0 +1,90 @@ +import { AffectedEntity, CurrentUser, extractGraphqlFields, GetCurrentUser, RequiredPermission } from "@comet/cms-api"; +import { Args, Field, ID, Info, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { ProductCategoriesArgs } from "@src/product-categories/dto/product-categories.args"; +import { ProductCategoryInput, ProductCategoryUpdateInput } from "@src/product-categories/dto/product-category.input"; +import { ProductCategoryScope } from "@src/product-categories/dto/product-category-scope.input"; +import { ProductCategory } from "@src/product-categories/entities/product-category.entity"; +import { GraphQLResolveInfo } from "graphql"; + +import { PaginatedProductCategories } from "./dto/paginated-product-categories"; +import { ProductCategoriesService, ProductCategoryValidationError } from "./product-categories.service"; + +@ObjectType() +class CreateProductCategoryPayload { + @Field(() => ProductCategory, { nullable: true }) + productCategory?: ProductCategory; + + @Field(() => [ProductCategoryValidationError], { nullable: false }) + errors: ProductCategoryValidationError[]; +} + +@ObjectType() +class UpdateProductCategoryPayload { + @Field(() => ProductCategory, { nullable: true }) + productCategory?: ProductCategory; + + @Field(() => [ProductCategoryValidationError], { nullable: false }) + errors: ProductCategoryValidationError[]; +} + +@Resolver(() => ProductCategory) +@RequiredPermission(["productCategories"]) +export class ProductCategoryResolver { + constructor(private readonly productCategoriesService: ProductCategoriesService) {} + + @Query(() => ProductCategory) + @AffectedEntity(ProductCategory) + async productCategory( + @Args("id", { type: () => ID }) + id: string, + ): Promise { + return this.productCategoriesService.findOneById(id); + } + + @Query(() => PaginatedProductCategories) + async productCategories(@Args() args: ProductCategoriesArgs, @Info() info: GraphQLResolveInfo): Promise { + const fields = extractGraphqlFields(info, { root: "nodes" }); + return this.productCategoriesService.findAll(args, fields); + } + + @Query(() => ProductCategory, { nullable: true }) + async productCategoryBySlug( + @Args("scope", { type: () => ProductCategoryScope }) scope: ProductCategoryScope, + @Args("slug") slug: string, + ): Promise { + return this.productCategoriesService.findBySlug(scope, slug); + } + + @Mutation(() => CreateProductCategoryPayload) + async createProductCategory( + @Args("scope", { type: () => ProductCategoryScope }) scope: ProductCategoryScope, + @Args("input", { type: () => ProductCategoryInput }) input: ProductCategoryInput, + @GetCurrentUser() user: CurrentUser, + ): Promise { + return this.productCategoriesService.create(scope, input, user); + } + + @Mutation(() => UpdateProductCategoryPayload) + @AffectedEntity(ProductCategory) + async updateProductCategory( + @Args("id", { type: () => ID }) id: string, + @Args("input", { type: () => ProductCategoryUpdateInput }) input: ProductCategoryUpdateInput, + @GetCurrentUser() user: CurrentUser, + ): Promise { + return this.productCategoriesService.update(id, input, user); + } + + @Mutation(() => Boolean) + @AffectedEntity(ProductCategory) + async deleteProductCategory( + @Args("id", { type: () => ID }) + id: string, + ): Promise { + return this.productCategoriesService.delete(id); + } + + @ResolveField(() => ProductCategory, { nullable: true }) + async parentCategory(@Parent() productCategory: ProductCategory): Promise { + return productCategory.parentCategory?.loadOrFail(); + } +} From 2f275739d4e1ba7461b0f1c6e60f1a9552b976d5 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:47:51 +0100 Subject: [PATCH 03/10] Add ProductCategories DataGrid with drag-and-drop reordering --- .../ProductCategoriesDataGrid.gql.ts | 55 +++++++ .../ProductCategoriesDataGrid.tsx | 147 ++++++++++++++++++ .../ProductCategoriesDataGridToolbar.tsx | 14 ++ 3 files changed, 216 insertions(+) create mode 100644 admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.gql.ts create mode 100644 admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx create mode 100644 admin/src/products/components/productCategoriesDataGrid/toolbar/ProductCategoriesDataGridToolbar.tsx diff --git a/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.gql.ts b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.gql.ts new file mode 100644 index 000000000..1b16afd6d --- /dev/null +++ b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.gql.ts @@ -0,0 +1,55 @@ +import { gql } from "@apollo/client"; + +const productCategoriesFragment = gql` + fragment ProductCategoriesGridItem on ProductCategory { + id + name + slug + position + parentCategory { + id + name + } + } +`; + +export const productCategoriesQuery = gql` + query ProductCategoriesGrid( + $scope: ProductCategoryScopeInput! + $offset: Int! + $limit: Int! + $sort: [ProductCategorySort!]! + $search: String + $filter: ProductCategoryFilter + ) { + productCategories(scope: $scope, offset: $offset, limit: $limit, sort: $sort, search: $search, filter: $filter) { + nodes { + ...ProductCategoriesGridItem + } + totalCount + } + } + ${productCategoriesFragment} +`; + +export const deleteProductCategoryMutation = gql` + mutation DeleteProductCategory($id: ID!) { + deleteProductCategory(id: $id) + } +`; + +export const updateProductCategoryPositionMutation = gql` + mutation UpdateProductCategoryPosition($id: ID!, $input: ProductCategoryUpdateInput!) { + updateProductCategory(id: $id, input: $input) { + productCategory { + id + position + updatedAt + } + errors { + code + field + } + } + } +`; diff --git a/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx new file mode 100644 index 000000000..3da851b7a --- /dev/null +++ b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx @@ -0,0 +1,147 @@ +import { useApolloClient, useQuery } from "@apollo/client"; +import { + CrudContextMenu, + type GridColDef, + StackLink, + useBufferedRowCount, + useDataGridRemote, + usePersistentColumnState, + useStackSwitchApi, +} from "@comet/admin"; +import { Edit as EditIcon } from "@comet/admin-icons"; +import { useContentScope } from "@comet/cms-admin"; +import { IconButton } from "@mui/material"; +import { DataGridPro, type GridRowOrderChangeParams, type GridSlotsComponent } from "@mui/x-data-grid-pro"; +import { useMemo } from "react"; +import { useIntl } from "react-intl"; + +import { deleteProductCategoryMutation, productCategoriesQuery, updateProductCategoryPositionMutation } from "./ProductCategoriesDataGrid.gql"; +import { + type GQLDeleteProductCategoryMutation, + type GQLDeleteProductCategoryMutationVariables, + type GQLProductCategoriesGridItemFragment, + type GQLProductCategoriesGridQuery, + type GQLProductCategoriesGridQueryVariables, + type GQLUpdateProductCategoryPositionMutation, + type GQLUpdateProductCategoryPositionMutationVariables, +} from "./ProductCategoriesDataGrid.gql.generated"; +import { ProductCategoriesDataGridToolbar } from "./toolbar/ProductCategoriesDataGridToolbar"; + +export function ProductCategoriesDataGrid() { + const client = useApolloClient(); + const intl = useIntl(); + const { scope } = useContentScope(); + const dataGridProps = { + ...useDataGridRemote({ + queryParamsPrefix: "productCategories", + }), + ...usePersistentColumnState("ProductCategoriesDataGrid"), + }; + const stackSwitchApi = useStackSwitchApi(); + + const handleRowClick = (params: { row: { id: string } }) => { + stackSwitchApi.activatePage("edit", params.row.id); + }; + + const handleRowOrderChange = async ({ row: { id }, targetIndex }: GridRowOrderChangeParams) => { + await client.mutate({ + mutation: updateProductCategoryPositionMutation, + variables: { id, input: { position: targetIndex + 1 } }, + awaitRefetchQueries: true, + refetchQueries: [productCategoriesQuery], + }); + }; + + const columns: GridColDef[] = useMemo( + () => [ + { + field: "name", + headerName: intl.formatMessage({ id: "productCategory.name", defaultMessage: "Name" }), + filterable: false, + sortable: false, + flex: 1, + minWidth: 150, + }, + { + field: "slug", + headerName: intl.formatMessage({ id: "productCategory.slug", defaultMessage: "Slug" }), + filterable: false, + sortable: false, + width: 200, + }, + { + field: "parentCategory", + headerName: intl.formatMessage({ id: "productCategory.parentCategory", defaultMessage: "Parent Category" }), + filterable: false, + sortable: false, + flex: 1, + minWidth: 150, + valueGetter: (_value: unknown, row: GQLProductCategoriesGridItemFragment) => row.parentCategory?.name, + }, + { + field: "actions", + headerName: "", + sortable: false, + filterable: false, + type: "actions", + align: "right", + pinned: "right", + width: 84, + renderCell: (params) => { + return ( + <> + + + + { + await client.mutate({ + mutation: deleteProductCategoryMutation, + variables: { id: params.row.id }, + }); + }} + refetchQueries={[productCategoriesQuery]} + /> + + ); + }, + }, + ], + [intl, client], + ); + + const { data, loading, error } = useQuery(productCategoriesQuery, { + variables: { + scope, + sort: [{ field: "position", direction: "ASC" as const }], + offset: 0, + limit: 1000, + }, + }); + + const rowCount = useBufferedRowCount(data?.productCategories.totalCount); + if (error) throw error; + + const rows = + data?.productCategories.nodes.map((node) => ({ + ...node, + __reorder__: node.name, + })) ?? []; + + return ( + + ); +} diff --git a/admin/src/products/components/productCategoriesDataGrid/toolbar/ProductCategoriesDataGridToolbar.tsx b/admin/src/products/components/productCategoriesDataGrid/toolbar/ProductCategoriesDataGridToolbar.tsx new file mode 100644 index 000000000..cb526c454 --- /dev/null +++ b/admin/src/products/components/productCategoriesDataGrid/toolbar/ProductCategoriesDataGridToolbar.tsx @@ -0,0 +1,14 @@ +import { Button, DataGridToolbar, FillSpace, StackLink } from "@comet/admin"; +import { Add as AddIcon } from "@comet/admin-icons"; +import { FormattedMessage } from "react-intl"; + +export function ProductCategoriesDataGridToolbar() { + return ( + + + + + ); +} From d482b98027e91c7139e2db72f3003acd492813e3 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:47:56 +0100 Subject: [PATCH 04/10] Add ProductCategory form and AsyncAutocompleteField --- ...oductCategoryAsyncAutocompleteField.gql.ts | 19 ++ .../ProductCategoryAsyncAutocompleteField.tsx | 60 ++++++ .../ProductCategoryForm.gql.ts | 57 ++++++ .../ProductCategoryForm.tsx | 174 ++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts create mode 100644 admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx create mode 100644 admin/src/products/components/productCategoryForm/ProductCategoryForm.gql.ts create mode 100644 admin/src/products/components/productCategoryForm/ProductCategoryForm.tsx diff --git a/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts new file mode 100644 index 000000000..74c2849dc --- /dev/null +++ b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts @@ -0,0 +1,19 @@ +import { gql } from "@apollo/client"; + +export const productCategoryAsyncAutocompleteFieldFragment = gql` + fragment ProductCategoryAsyncAutocompleteFieldProductCategory on ProductCategory { + id + name + } +`; + +export const productCategoryAsyncAutocompleteFieldQuery = gql` + query ProductCategoryAsyncAutocompleteField($scope: ProductCategoryScopeInput!, $search: String, $filter: ProductCategoryFilter) { + productCategories(scope: $scope, search: $search, filter: $filter) { + nodes { + ...ProductCategoryAsyncAutocompleteFieldProductCategory + } + } + } + ${productCategoryAsyncAutocompleteFieldFragment} +`; diff --git a/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx new file mode 100644 index 000000000..394420b8e --- /dev/null +++ b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx @@ -0,0 +1,60 @@ +import { useApolloClient } from "@apollo/client"; +import { AsyncAutocompleteField, type AsyncAutocompleteFieldProps } from "@comet/admin"; +import { useContentScope } from "@comet/cms-admin"; +import { type FunctionComponent } from "react"; + +import { productCategoryAsyncAutocompleteFieldQuery } from "./ProductCategoryAsyncAutocompleteField.gql"; +import { + type GQLProductCategoryAsyncAutocompleteFieldProductCategoryFragment, + type GQLProductCategoryAsyncAutocompleteFieldQuery, + type GQLProductCategoryAsyncAutocompleteFieldQueryVariables, +} from "./ProductCategoryAsyncAutocompleteField.gql.generated"; + +export type ProductCategoryAsyncAutocompleteFieldOption = GQLProductCategoryAsyncAutocompleteFieldProductCategoryFragment; + +type ProductCategoryAsyncAutocompleteFieldProps = Omit< + AsyncAutocompleteFieldProps, + "loadOptions" +> & { + excludeId?: string; +}; + +export const ProductCategoryAsyncAutocompleteField: FunctionComponent = ({ + name, + excludeId, + clearable = true, + disabled = false, + variant = "horizontal", + fullWidth = true, + ...restProps +}) => { + const client = useApolloClient(); + const { scope } = useContentScope(); + + return ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + {...restProps} + loadOptions={async (search) => { + const { data } = await client.query< + GQLProductCategoryAsyncAutocompleteFieldQuery, + GQLProductCategoryAsyncAutocompleteFieldQueryVariables + >({ + query: productCategoryAsyncAutocompleteFieldQuery, + variables: { + scope, + search, + filter: excludeId ? { id: { notEqual: excludeId } } : undefined, + }, + }); + return data.productCategories.nodes; + }} + /> + ); +}; diff --git a/admin/src/products/components/productCategoryForm/ProductCategoryForm.gql.ts b/admin/src/products/components/productCategoryForm/ProductCategoryForm.gql.ts new file mode 100644 index 000000000..04824b799 --- /dev/null +++ b/admin/src/products/components/productCategoryForm/ProductCategoryForm.gql.ts @@ -0,0 +1,57 @@ +import { gql } from "@apollo/client"; + +export const productCategoryFormFragment = gql` + fragment ProductCategoryFormDetails on ProductCategory { + name + slug + parentCategory { + id + name + } + } +`; + +export const productCategoryQuery = gql` + query ProductCategory($id: ID!) { + productCategory(id: $id) { + id + updatedAt + ...ProductCategoryFormDetails + } + } + ${productCategoryFormFragment} +`; + +export const createProductCategoryMutation = gql` + mutation CreateProductCategory($scope: ProductCategoryScopeInput!, $input: ProductCategoryInput!) { + createProductCategory(scope: $scope, input: $input) { + productCategory { + id + updatedAt + ...ProductCategoryFormDetails + } + errors { + code + field + } + } + } + ${productCategoryFormFragment} +`; + +export const updateProductCategoryMutation = gql` + mutation UpdateProductCategory($id: ID!, $input: ProductCategoryUpdateInput!) { + updateProductCategory(id: $id, input: $input) { + productCategory { + id + updatedAt + ...ProductCategoryFormDetails + } + errors { + code + field + } + } + } + ${productCategoryFormFragment} +`; diff --git a/admin/src/products/components/productCategoryForm/ProductCategoryForm.tsx b/admin/src/products/components/productCategoryForm/ProductCategoryForm.tsx new file mode 100644 index 000000000..800b883e1 --- /dev/null +++ b/admin/src/products/components/productCategoryForm/ProductCategoryForm.tsx @@ -0,0 +1,174 @@ +import { useApolloClient, useQuery } from "@apollo/client"; +import { FieldSet, filterByFragment, FinalForm, type FinalFormSubmitEvent, Loading, TextField, useFormApiRef, useStackSwitchApi } from "@comet/admin"; +import { queryUpdatedAt, resolveHasSaveConflict, useContentScope, useFormSaveConflict } from "@comet/cms-admin"; +import { type GQLProductCategoryValidationErrorCode } from "@src/graphql.generated"; +import { FORM_ERROR, type FormApi } from "final-form"; +import isEqual from "lodash.isequal"; +import { type ReactNode, useMemo } from "react"; +import { FormattedMessage } from "react-intl"; + +import { ProductCategoryAsyncAutocompleteField } from "../productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField"; +import { + createProductCategoryMutation, + productCategoryFormFragment, + productCategoryQuery, + updateProductCategoryMutation, +} from "./ProductCategoryForm.gql"; +import { + type GQLCreateProductCategoryMutation, + type GQLCreateProductCategoryMutationVariables, + type GQLProductCategoryFormDetailsFragment, + type GQLProductCategoryQuery, + type GQLProductCategoryQueryVariables, + type GQLUpdateProductCategoryMutation, + type GQLUpdateProductCategoryMutationVariables, +} from "./ProductCategoryForm.gql.generated"; + +type FormValues = GQLProductCategoryFormDetailsFragment; + +interface FormProps { + id?: string; +} + +const submissionErrorMessages: Record = { + SLUG_ALREADY_EXISTS: , +}; + +export function ProductCategoryForm({ id }: FormProps) { + const client = useApolloClient(); + const mode = id ? "edit" : "add"; + const formApiRef = useFormApiRef(); + const stackSwitchApi = useStackSwitchApi(); + const { scope } = useContentScope(); + + const { data, error, loading, refetch } = useQuery( + productCategoryQuery, + id ? { variables: { id } } : { skip: true }, + ); + + const initialValues = useMemo>( + () => + data?.productCategory + ? { + ...filterByFragment(productCategoryFormFragment, data.productCategory), + } + : {}, + [data], + ); + + const saveConflict = useFormSaveConflict({ + checkConflict: async () => { + const updatedAt = await queryUpdatedAt(client, "productCategory", id); + return resolveHasSaveConflict(data?.productCategory.updatedAt, updatedAt); + }, + formApiRef, + loadLatestVersion: async () => { + await refetch(); + }, + }); + + const handleSubmit = async (formValues: FormValues, form: FormApi, event: FinalFormSubmitEvent) => { + if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected"); + + const output = { + name: formValues.name, + slug: formValues.slug, + parentCategory: formValues.parentCategory ? formValues.parentCategory.id : null, + }; + + if (mode === "edit") { + if (!id) throw new Error(); + const { data: mutationResponse } = await client.mutate({ + mutation: updateProductCategoryMutation, + variables: { id, input: output }, + }); + + if (mutationResponse?.updateProductCategory.errors.length) { + return mutationResponse.updateProductCategory.errors.reduce( + (submissionErrors: Record, error: { code: GQLProductCategoryValidationErrorCode; field?: string | null }) => { + const errorMessage = submissionErrorMessages[error.code]; + if (error.field) { + submissionErrors[error.field] = errorMessage; + } else { + submissionErrors[FORM_ERROR] = errorMessage; + } + return submissionErrors; + }, + {} as Record, + ); + } + } else { + const { data: mutationResponse } = await client.mutate({ + mutation: createProductCategoryMutation, + variables: { input: output, scope }, + }); + + if (mutationResponse?.createProductCategory.errors.length) { + return mutationResponse.createProductCategory.errors.reduce( + (submissionErrors: Record, error: { code: GQLProductCategoryValidationErrorCode; field?: string | null }) => { + const errorMessage = submissionErrorMessages[error.code]; + if (error.field) { + submissionErrors[error.field] = errorMessage; + } else { + submissionErrors[FORM_ERROR] = errorMessage; + } + return submissionErrors; + }, + {} as Record, + ); + } + + const newId = mutationResponse?.createProductCategory.productCategory?.id; + if (newId) { + setTimeout(() => stackSwitchApi.activatePage("edit", newId)); + } + } + }; + + if (error) throw error; + if (loading) return ; + + return ( + + apiRef={formApiRef} + onSubmit={handleSubmit} + mode={mode} + initialValues={initialValues} + initialValuesEqual={isEqual} + subscription={{}} + > + {() => ( + <> + {saveConflict.dialogs} +
}> + } + /> + } + helperText={ + + } + /> + } + excludeId={id} + /> +
+ + )} + + ); +} From 28f6bf0ffb7e28ebc47b1b70bf15c7aca406cd46 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:49:09 +0100 Subject: [PATCH 05/10] Add ProductCategoriesPage with Stack navigation and entity toolbar --- admin/src/products/ProductCategoriesPage.tsx | 53 +++++++++ .../ProductCategoryToolbar.gql.ts | 10 ++ .../ProductCategoryToolbar.tsx | 102 ++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 admin/src/products/ProductCategoriesPage.tsx create mode 100644 admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.gql.ts create mode 100644 admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.tsx diff --git a/admin/src/products/ProductCategoriesPage.tsx b/admin/src/products/ProductCategoriesPage.tsx new file mode 100644 index 000000000..d9b288694 --- /dev/null +++ b/admin/src/products/ProductCategoriesPage.tsx @@ -0,0 +1,53 @@ +import { + SaveBoundary, + Stack, + StackMainContent, + StackPage, + StackSwitch, + StackToolbar, + ToolbarAutomaticTitleItem, + ToolbarBackButton, +} from "@comet/admin"; +import { ContentScopeIndicator } from "@comet/cms-admin"; +import { type FunctionComponent } from "react"; +import { FormattedMessage } from "react-intl"; + +import { ProductCategoriesDataGrid } from "./components/productCategoriesDataGrid/ProductCategoriesDataGrid"; +import { ProductCategoryForm } from "./components/productCategoryForm/ProductCategoryForm"; +import { ProductCategoryToolbar } from "./components/productCategoryToolbar/ProductCategoryToolbar"; + +export const ProductCategoriesPage: FunctionComponent = () => { + return ( + }> + + + }> + + + + + + + + + + + + + + + + + {(id) => ( + + + + + + + )} + + + + ); +}; diff --git a/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.gql.ts b/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.gql.ts new file mode 100644 index 000000000..5f14a181c --- /dev/null +++ b/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.gql.ts @@ -0,0 +1,10 @@ +import { gql } from "@apollo/client"; + +export const productCategoryToolbarQuery = gql` + query ProductCategoryToolbar($id: ID!) { + productCategory(id: $id) { + id + name + } + } +`; diff --git a/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.tsx b/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.tsx new file mode 100644 index 000000000..1f85b9206 --- /dev/null +++ b/admin/src/products/components/productCategoryToolbar/ProductCategoryToolbar.tsx @@ -0,0 +1,102 @@ +import { useQuery } from "@apollo/client"; +import { + FillSpace, + Loading, + LocalErrorScopeApolloContext, + SaveBoundarySaveButton, + StackPageTitle, + StackToolbar, + ToolbarActions, + ToolbarAutomaticTitleItem, + ToolbarBackButton, + ToolbarItem, + Tooltip, +} from "@comet/admin"; +import { Error } from "@comet/admin-icons"; +import { ContentScopeIndicator } from "@comet/cms-admin"; +import { Box, Typography, useTheme } from "@mui/material"; +import { type FunctionComponent, type ReactNode } from "react"; +import { FormattedMessage } from "react-intl"; + +import { productCategoryToolbarQuery } from "./ProductCategoryToolbar.gql"; +import { type GQLProductCategoryToolbarQuery, type GQLProductCategoryToolbarQueryVariables } from "./ProductCategoryToolbar.gql.generated"; + +interface ProductCategoryToolbarProps { + id?: string; + additionalActions?: ReactNode; +} + +export const ProductCategoryToolbar: FunctionComponent = ({ id, additionalActions }) => { + const theme = useTheme(); + + const { data, loading, error } = useQuery( + productCategoryToolbarQuery, + id != null + ? { + variables: { id }, + context: LocalErrorScopeApolloContext, + } + : { skip: true }, + ); + + if (loading) { + return ( + }> + + + + + ); + } + + const title = data?.productCategory.name; + + return ( + + }> + + + {title ? ( + + + {title} + + + ) : ( + + )} + + {error != null && ( + + + + + + + + + + } + > + + + + + + )} + + + + + {additionalActions} + + + + + ); +}; From 3985becf6d54aa0c58797919fbc28f177088745a Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:49:49 +0100 Subject: [PATCH 06/10] Add ProductCategories to MasterMenu --- admin/src/common/MasterMenu.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/admin/src/common/MasterMenu.tsx b/admin/src/common/MasterMenu.tsx index a266ccaf1..de5740c55 100644 --- a/admin/src/common/MasterMenu.tsx +++ b/admin/src/common/MasterMenu.tsx @@ -1,4 +1,4 @@ -import { Assets, Dashboard, PageTree, Snips, Tag, Wrench } from "@comet/admin-icons"; +import { Assets, Dashboard, Domain, PageTree, Snips, Tag, Wrench } from "@comet/admin-icons"; import { ContentScopeIndicator, createRedirectsPage, @@ -14,6 +14,7 @@ import { DashboardPage } from "@src/dashboard/DashboardPage"; import { Link } from "@src/documents/links/Link"; import { Page } from "@src/documents/pages/Page"; import { EditFooterPage } from "@src/footers/EditFooterPage"; +import { ProductCategoriesPage } from "@src/products/ProductCategoriesPage"; import { ProductsPage } from "@src/products/ProductsPage"; import { FormattedMessage } from "react-intl"; @@ -61,6 +62,16 @@ export const masterMenuData: MasterMenuData = [ }, requiredPermission: "products", }, + { + type: "route", + primary: , + icon: , + route: { + path: "/product-categories", + component: ProductCategoriesPage, + }, + requiredPermission: "productCategories", + }, { type: "route", primary: , From 8f8eb87662c2df921faf7d80c76cf4514c054f35 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:55:21 +0100 Subject: [PATCH 07/10] Add category relation to Product entity and form --- ...oductCategoryAsyncAutocompleteField.gql.ts | 2 +- .../ProductCategoryAsyncAutocompleteField.tsx | 2 +- .../components/productForm/ProductForm.gql.ts | 4 +++ .../components/productForm/ProductForm.tsx | 6 +++++ api/schema.gql | 4 +++ .../db/migrations/Migration20260316065244.ts | 15 +++++++++++ api/src/products/dto/product.filter.ts | 8 +++++- api/src/products/dto/product.input.ts | 9 +++++-- api/src/products/entities/product.entity.ts | 7 ++++- api/src/products/product.resolver.ts | 27 ++++++++++++++----- api/src/products/products.module.ts | 3 ++- api/src/products/products.service.ts | 21 ++++++++++++--- 12 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 api/src/db/migrations/Migration20260316065244.ts diff --git a/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts index 74c2849dc..53f7e9439 100644 --- a/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts +++ b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.gql.ts @@ -1,6 +1,6 @@ import { gql } from "@apollo/client"; -export const productCategoryAsyncAutocompleteFieldFragment = gql` +const productCategoryAsyncAutocompleteFieldFragment = gql` fragment ProductCategoryAsyncAutocompleteFieldProductCategory on ProductCategory { id name diff --git a/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx index 394420b8e..27af2cb0d 100644 --- a/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx +++ b/admin/src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField.tsx @@ -10,7 +10,7 @@ import { type GQLProductCategoryAsyncAutocompleteFieldQueryVariables, } from "./ProductCategoryAsyncAutocompleteField.gql.generated"; -export type ProductCategoryAsyncAutocompleteFieldOption = GQLProductCategoryAsyncAutocompleteFieldProductCategoryFragment; +type ProductCategoryAsyncAutocompleteFieldOption = GQLProductCategoryAsyncAutocompleteFieldProductCategoryFragment; type ProductCategoryAsyncAutocompleteFieldProps = Omit< AsyncAutocompleteFieldProps, diff --git a/admin/src/products/components/productForm/ProductForm.gql.ts b/admin/src/products/components/productForm/ProductForm.gql.ts index ddb43ed10..f8181d0ac 100644 --- a/admin/src/products/components/productForm/ProductForm.gql.ts +++ b/admin/src/products/components/productForm/ProductForm.gql.ts @@ -11,6 +11,10 @@ export const productFormFragment = gql` productStatus publishedAt isPublished + category { + id + name + } } `; diff --git a/admin/src/products/components/productForm/ProductForm.tsx b/admin/src/products/components/productForm/ProductForm.tsx index 1557fe959..23e5764f2 100644 --- a/admin/src/products/components/productForm/ProductForm.tsx +++ b/admin/src/products/components/productForm/ProductForm.tsx @@ -28,6 +28,7 @@ import { validatePositiveNumber } from "@src/common/validators/validatePositiveN import { validateSkuFormat } from "@src/common/validators/validateSkuFormat"; import { validateSlug } from "@src/common/validators/validateSlug"; import { type GQLProductValidationErrorCode } from "@src/graphql.generated"; +import { ProductCategoryAsyncAutocompleteField } from "@src/products/components/productCategoryAsyncAutocompleteField/ProductCategoryAsyncAutocompleteField"; import { ProductStatusSelectField } from "@src/products/components/productStatusSelectField/ProductStatusSelectField"; import { ProductTypeSelectField } from "@src/products/components/productTypeSelectField/ProductTypeSelectField"; import { FORM_ERROR, type FormApi } from "final-form"; @@ -113,6 +114,7 @@ export function ProductForm({ id }: FormProps) { ...formValues, publishedAt: formValues.publishedAt ? formValues.publishedAt.toISOString() : null, mainImage: rootBlocks.mainImage.state2Output(formValues.mainImage), + category: formValues.category ? formValues.category.id : null, }; if (mode === "edit") { @@ -239,6 +241,10 @@ export function ProductForm({ id }: FormProps) { name="productType" label={} /> + } + />
}> { + this.addSql(`alter table "Product" add column "category" uuid null;`); + this.addSql(`alter table "Product" add constraint "Product_category_foreign" foreign key ("category") references "ProductCategory" ("id") on update cascade on delete set null;`); + } + + override async down(): Promise { + this.addSql(`alter table "Product" drop constraint "Product_category_foreign";`); + this.addSql(`alter table "Product" drop column "category";`); + } + +} diff --git a/api/src/products/dto/product.filter.ts b/api/src/products/dto/product.filter.ts index f4a7b4189..f343c5eac 100644 --- a/api/src/products/dto/product.filter.ts +++ b/api/src/products/dto/product.filter.ts @@ -1,4 +1,4 @@ -import { BooleanFilter, createEnumFilter, DateTimeFilter, IdFilter, NumberFilter, StringFilter } from "@comet/cms-api"; +import { BooleanFilter, createEnumFilter, DateTimeFilter, IdFilter, ManyToOneFilter, NumberFilter, StringFilter } from "@comet/cms-api"; import { Field, InputType } from "@nestjs/graphql"; import { ProductStatus, ProductType } from "@src/products/entities/product.entity"; import { Type } from "class-transformer"; @@ -66,6 +66,12 @@ export class ProductFilter { @Type(() => ProductTypeFilter) productType?: typeof ProductTypeFilter; + @Field(() => ManyToOneFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => ManyToOneFilter) + category?: ManyToOneFilter; + @Field(() => DateTimeFilter, { nullable: true }) @ValidateNested() @IsOptional() diff --git a/api/src/products/dto/product.input.ts b/api/src/products/dto/product.input.ts index da2b5b32d..c937e8f8b 100644 --- a/api/src/products/dto/product.input.ts +++ b/api/src/products/dto/product.input.ts @@ -1,8 +1,8 @@ import { BlockInputInterface, DamImageBlock, isBlockInputInterface, IsSlug, PartialType, RootBlockInputScalar } from "@comet/cms-api"; -import { Field, Float, InputType } from "@nestjs/graphql"; +import { Field, Float, ID, InputType } from "@nestjs/graphql"; import { ProductStatus, ProductType } from "@src/products/entities/product.entity"; import { Transform } from "class-transformer"; -import { IsBoolean, IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator"; +import { IsBoolean, IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, ValidateNested } from "class-validator"; @InputType() export class ProductInput { @@ -51,6 +51,11 @@ export class ProductInput { @Field(() => ProductType) productType: ProductType; + @IsOptional() + @IsUUID() + @Field(() => ID, { nullable: true }) + category?: string; + @IsOptional() @Field(() => RootBlockInputScalar(DamImageBlock), { nullable: true }) @Transform(({ value }) => (isBlockInputInterface(value) ? value : DamImageBlock.blockInputFactory(value)), { toClassOnly: true }) diff --git a/api/src/products/entities/product.entity.ts b/api/src/products/entities/product.entity.ts index 702fc450b..4a48c082a 100644 --- a/api/src/products/entities/product.entity.ts +++ b/api/src/products/entities/product.entity.ts @@ -1,6 +1,7 @@ import { BlockDataInterface, DamImageBlock, RootBlock, RootBlockDataScalar, RootBlockEntity, RootBlockType, ScopedEntity } from "@comet/cms-api"; -import { BaseEntity, Entity, Enum, OptionalProps, PrimaryKey, Property } from "@mikro-orm/postgresql"; +import { BaseEntity, Entity, Enum, ManyToOne, OptionalProps, PrimaryKey, Property, Ref } from "@mikro-orm/postgresql"; import { Field, Float, ID, ObjectType, registerEnumType } from "@nestjs/graphql"; +import { ProductCategory } from "@src/product-categories/entities/product-category.entity"; import { v4 as uuid } from "uuid"; export enum ProductStatus { @@ -70,6 +71,10 @@ export class Product extends BaseEntity { @Field(() => ProductType) productType: ProductType; + @ManyToOne(() => ProductCategory, { ref: true, nullable: true }) + @Field(() => ProductCategory, { nullable: true }) + category?: Ref; + @RootBlock(DamImageBlock) @Property({ type: new RootBlockType(DamImageBlock), nullable: true }) @Field(() => RootBlockDataScalar(DamImageBlock), { nullable: true }) diff --git a/api/src/products/product.resolver.ts b/api/src/products/product.resolver.ts index 14cb598fe..623edb82e 100644 --- a/api/src/products/product.resolver.ts +++ b/api/src/products/product.resolver.ts @@ -1,9 +1,19 @@ -import { AffectedEntity, CurrentUser, DamImageBlock, GetCurrentUser, RequiredPermission, RootBlockDataScalar } from "@comet/cms-api"; -import { Args, Field, ID, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { + AffectedEntity, + CurrentUser, + DamImageBlock, + extractGraphqlFields, + GetCurrentUser, + RequiredPermission, + RootBlockDataScalar, +} from "@comet/cms-api"; +import { Args, Field, ID, Info, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { ProductCategory } from "@src/product-categories/entities/product-category.entity"; import { ProductInput, ProductUpdateInput } from "@src/products/dto/product.input"; import { ProductScope } from "@src/products/dto/product-scope.input"; import { ProductsArgs } from "@src/products/dto/products.args"; import { Product } from "@src/products/entities/product.entity"; +import { GraphQLResolveInfo } from "graphql"; import { PaginatedProducts } from "./dto/paginated-products"; import { ProductsService, ProductValidationError } from "./products.service"; @@ -41,11 +51,9 @@ export class ProductResolver { } @Query(() => PaginatedProducts) - async products( - @Args() - args: ProductsArgs, - ): Promise { - return this.productsService.findAll(args); + async products(@Args() args: ProductsArgs, @Info() info: GraphQLResolveInfo): Promise { + const fields = extractGraphqlFields(info, { root: "nodes" }); + return this.productsService.findAll(args, fields); } @Query(() => Product, { nullable: true }) @@ -81,6 +89,11 @@ export class ProductResolver { return this.productsService.delete(id); } + @ResolveField(() => ProductCategory, { nullable: true }) + async category(@Parent() product: Product): Promise { + return product.category?.loadOrFail(); + } + @ResolveField(() => RootBlockDataScalar(DamImageBlock), { nullable: true }) async mainImage(@Parent() product: Product): Promise { if (!product.mainImage) { diff --git a/api/src/products/products.module.ts b/api/src/products/products.module.ts index 8f6aa266f..30db8f616 100644 --- a/api/src/products/products.module.ts +++ b/api/src/products/products.module.ts @@ -1,12 +1,13 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; import { Module } from "@nestjs/common"; +import { ProductCategory } from "@src/product-categories/entities/product-category.entity"; import { Product } from "./entities/product.entity"; import { ProductResolver } from "./product.resolver"; import { ProductsService } from "./products.service"; @Module({ - imports: [MikroOrmModule.forFeature([Product])], + imports: [MikroOrmModule.forFeature([Product, ProductCategory])], providers: [ProductsService, ProductResolver], }) export class ProductsModule {} diff --git a/api/src/products/products.service.ts b/api/src/products/products.service.ts index 40a4827fd..2638c9084 100644 --- a/api/src/products/products.service.ts +++ b/api/src/products/products.service.ts @@ -1,7 +1,8 @@ import { BlockDataInterface, BlocksTransformerService, CurrentUser, gqlArgsToMikroOrmQuery, gqlSortToMikroOrmOrderBy } from "@comet/cms-api"; -import { EntityManager, FindOptions } from "@mikro-orm/postgresql"; +import { EntityManager, FindOptions, Reference } from "@mikro-orm/postgresql"; import { Injectable } from "@nestjs/common"; import { Field, ObjectType, registerEnumType } 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"; @@ -36,13 +37,21 @@ export class ProductsService { return this.entityManager.findOneOrFail(Product, id); } - async findAll({ scope, search, filter, sort, offset, limit }: ProductsArgs): Promise { + async findAll({ scope, search, filter, sort, offset, limit }: ProductsArgs, fields?: string[]): 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 populate: string[] = []; + if (fields?.includes("category")) { + populate.push("category"); + } + if (populate.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options.populate = populate as any; + } const [entities, totalCount] = await this.entityManager.findAndCount(Product, where, options); return new PaginatedProducts(entities, totalCount); } @@ -58,11 +67,12 @@ export class ProductsService { return { errors }; } - const { mainImage: mainImageInput, ...assignInput } = input; + const { mainImage: mainImageInput, category: categoryInput, ...assignInput } = input; const product = this.entityManager.create(Product, { ...assignInput, ...scope, ...(mainImageInput ? { mainImage: mainImageInput.transformToBlockData() } : {}), + ...(categoryInput ? { category: Reference.create(await this.entityManager.findOneOrFail(ProductCategory, categoryInput)) } : {}), }); await this.entityManager.flush(); return { product, errors: [] }; @@ -76,11 +86,14 @@ export class ProductsService { return { errors }; } - const { mainImage: mainImageInput, ...assignInput } = input; + const { mainImage: mainImageInput, category: categoryInput, ...assignInput } = input; product.assign({ ...assignInput }); if (mainImageInput) { product.mainImage = mainImageInput.transformToBlockData(); } + if (categoryInput !== undefined) { + product.category = categoryInput ? Reference.create(await this.entityManager.findOneOrFail(ProductCategory, categoryInput)) : undefined; + } await this.entityManager.flush(); return { product, errors: [] }; } From 32b498a84a10a6d1b85e776ddac2f4db2ffdf7de Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 07:57:31 +0100 Subject: [PATCH 08/10] Remove pagination limit constraint for non-paginated ProductCategories grid --- .../productCategoriesDataGrid/ProductCategoriesDataGrid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx index 3da851b7a..4bb647ba6 100644 --- a/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx +++ b/admin/src/products/components/productCategoriesDataGrid/ProductCategoriesDataGrid.tsx @@ -115,7 +115,7 @@ export function ProductCategoriesDataGrid() { scope, sort: [{ field: "position", direction: "ASC" as const }], offset: 0, - limit: 1000, + limit: 100, }, }); From ce663a3ce8f5f71c15fa2136b8277fbe707cceee Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 08:01:15 +0100 Subject: [PATCH 09/10] Show category column in Products DataGrid --- .../components/productsDataGrid/ProductsGrid.gql.ts | 4 ++++ .../products/components/productsDataGrid/ProductsGrid.tsx | 7 +++++++ specs/product/01-product.md | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts b/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts index 2fc5feca8..a572c3792 100644 --- a/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts +++ b/admin/src/products/components/productsDataGrid/ProductsGrid.gql.ts @@ -11,6 +11,10 @@ const productsFragment = gql` productStatus publishedAt isPublished + category { + id + name + } } `; diff --git a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx index 5de7a559d..7d55c41aa 100644 --- a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx +++ b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx @@ -85,6 +85,13 @@ export function ProductsGrid() { headerName: intl.formatMessage({ id: "product.sku", defaultMessage: "SKU" }), width: 150, }, + { + field: "category", + headerName: intl.formatMessage({ id: "product.category", defaultMessage: "Category" }), + width: 150, + sortable: false, + valueGetter: (_value: unknown, row: GQLProductsGridItemFragment) => row.category?.name, + }, { field: "productType", headerName: intl.formatMessage({ id: "product.productType", defaultMessage: "Type" }), diff --git a/specs/product/01-product.md b/specs/product/01-product.md index 6b3e84712..82b92f6cb 100644 --- a/specs/product/01-product.md +++ b/specs/product/01-product.md @@ -40,7 +40,7 @@ The Product entity represents a product in the catalog. Products are scoped by d ## DataGrid -Columns: mainImage as thumbnail, name, sku, productType as editable chip, price, productStatus as editable chip, publishedAt, isPublished. +Columns: mainImage as thumbnail, name, sku, category (show category name), 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. From 7c2ec7d520fc33ce7db28d01bbcae079d5f2f33b Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Mon, 16 Mar 2026 08:13:09 +0100 Subject: [PATCH 10/10] Add category filter with Autocomplete to Products DataGrid --- admin/package.json | 3 +- .../productsDataGrid/ProductsGrid.tsx | 2 + .../filter/ProductCategoryFilter.gql.ts | 12 +++ .../filter/ProductCategoryFilter.tsx | 99 +++++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.gql.ts create mode 100644 admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.tsx diff --git a/admin/package.json b/admin/package.json index dd40bb332..0e353da11 100644 --- a/admin/package.json +++ b/admin/package.json @@ -54,7 +54,8 @@ "react-final-form": "^6.5.9", "react-intl": "^7.1.14", "react-router": "^5.3.4", - "react-router-dom": "^5.3.4" + "react-router-dom": "^5.3.4", + "use-debounce": "^10.0.6" }, "devDependencies": { "@comet/admin-generator": "8.18.0", diff --git a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx index 7d55c41aa..c70785495 100644 --- a/admin/src/products/components/productsDataGrid/ProductsGrid.tsx +++ b/admin/src/products/components/productsDataGrid/ProductsGrid.tsx @@ -25,6 +25,7 @@ import { ProductTypeChipEditableForProduct } from "@src/products/components/prod import { useMemo } from "react"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; +import { ProductCategoryFilterOperators } from "./filter/ProductCategoryFilter"; import { deleteProductMutation, productsQuery } from "./ProductsGrid.gql"; import { type GQLDeleteProductMutation, @@ -91,6 +92,7 @@ export function ProductsGrid() { width: 150, sortable: false, valueGetter: (_value: unknown, row: GQLProductsGridItemFragment) => row.category?.name, + filterOperators: ProductCategoryFilterOperators, }, { field: "productType", diff --git a/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.gql.ts b/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.gql.ts new file mode 100644 index 000000000..4c75ee2a6 --- /dev/null +++ b/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.gql.ts @@ -0,0 +1,12 @@ +import { gql } from "@apollo/client"; + +export const productCategoryFilterQuery = gql` + query ProductCategoryFilter($scope: ProductCategoryScopeInput!, $offset: Int!, $limit: Int!, $search: String) { + productCategories(scope: $scope, offset: $offset, limit: $limit, search: $search) { + nodes { + id + name + } + } + } +`; diff --git a/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.tsx b/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.tsx new file mode 100644 index 000000000..c7e2b7b01 --- /dev/null +++ b/admin/src/products/components/productsDataGrid/filter/ProductCategoryFilter.tsx @@ -0,0 +1,99 @@ +import { useQuery } from "@apollo/client"; +import { ClearInputAdornment } from "@comet/admin"; +import { useContentScope } from "@comet/cms-admin"; +import Autocomplete from "@mui/material/Autocomplete"; +import { type GridFilterInputValueProps, type GridFilterOperator, useGridRootProps } from "@mui/x-data-grid-pro"; +import { useCallback, useState } from "react"; +import { useIntl } from "react-intl"; +import { useDebounce } from "use-debounce"; + +import { productCategoryFilterQuery } from "./ProductCategoryFilter.gql"; +import { type GQLProductCategoryFilterQuery, type GQLProductCategoryFilterQueryVariables } from "./ProductCategoryFilter.gql.generated"; + +function ProductCategoryFilter({ item, applyValue, apiRef }: GridFilterInputValueProps) { + const intl = useIntl(); + const [search, setSearch] = useState(undefined); + const [debouncedSearch] = useDebounce(search, 500); + const rootProps = useGridRootProps(); + const { scope } = useContentScope(); + + const { data } = useQuery(productCategoryFilterQuery, { + variables: { + scope, + offset: 0, + limit: 10, + search: debouncedSearch, + }, + }); + + const handleApplyValue = useCallback( + (value: string | undefined) => { + applyValue({ + ...item, + id: item.id, + operator: "equals", + value, + }); + }, + [applyValue, item], + ); + + return ( + x} + disableClearable + isOptionEqualToValue={(option, value) => option.id == value} + getOptionLabel={(option) => { + return option.name ?? data?.productCategories.nodes.find((c) => c.id === option)?.name ?? option; + }} + onChange={(event, value, reason) => { + handleApplyValue(value ? value.id : undefined); + }} + renderInput={(params) => ( + { + setSearch(event.target.value); + }} + label={apiRef.current.getLocaleText("filterPanelInputLabel")} + slotProps={{ + inputLabel: { shrink: true }, + input: { + ...params.InputProps, + endAdornment: ( + <> + handleApplyValue(undefined)} + /> + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + /> + ); +} + +export const ProductCategoryFilterOperators: GridFilterOperator[] = [ + { + value: "equals", + getApplyFilterFn: () => { + throw new Error("not implemented, we filter server side"); + }, + InputComponent: ProductCategoryFilter, + }, +];