From 53916763ad50c3de7ca52d11ce56a9a51cd14e9b Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 13:37:19 +0100 Subject: [PATCH 01/12] add comet-api-graphql skill --- package-skills/comet-api-graphql/SKILL.md | 188 ++++++++++++ .../references/feature-01-position.md | 257 ++++++++++++++++ .../references/feature-02-validation.md | 215 +++++++++++++ .../references/feature-03-dedicated-arg.md | 104 +++++++ .../references/feature-04-slug.md | 68 +++++ .../references/feature-05-scope.md | 287 ++++++++++++++++++ .../references/field-01-scalar.md | 188 ++++++++++++ .../references/field-02-enum.md | 83 +++++ .../references/field-03-relation.md | 217 +++++++++++++ .../references/field-04-block.md | 86 ++++++ .../references/field-05-json-embedded.md | 65 ++++ .../references/gen-00-resolver.md | 234 ++++++++++++++ .../references/gen-01-input.md | 160 ++++++++++ .../references/gen-02-filter.md | 87 ++++++ .../references/gen-03-sort.md | 65 ++++ .../references/gen-04-args.md | 39 +++ .../references/gen-05-paginated.md | 16 + .../references/gen-06-nested-input.md | 63 ++++ .../references/gen-07-service.md | 205 +++++++++++++ 19 files changed, 2627 insertions(+) create mode 100644 package-skills/comet-api-graphql/SKILL.md create mode 100644 package-skills/comet-api-graphql/references/feature-01-position.md create mode 100644 package-skills/comet-api-graphql/references/feature-02-validation.md create mode 100644 package-skills/comet-api-graphql/references/feature-03-dedicated-arg.md create mode 100644 package-skills/comet-api-graphql/references/feature-04-slug.md create mode 100644 package-skills/comet-api-graphql/references/feature-05-scope.md create mode 100644 package-skills/comet-api-graphql/references/field-01-scalar.md create mode 100644 package-skills/comet-api-graphql/references/field-02-enum.md create mode 100644 package-skills/comet-api-graphql/references/field-03-relation.md create mode 100644 package-skills/comet-api-graphql/references/field-04-block.md create mode 100644 package-skills/comet-api-graphql/references/field-05-json-embedded.md create mode 100644 package-skills/comet-api-graphql/references/gen-00-resolver.md create mode 100644 package-skills/comet-api-graphql/references/gen-01-input.md create mode 100644 package-skills/comet-api-graphql/references/gen-02-filter.md create mode 100644 package-skills/comet-api-graphql/references/gen-03-sort.md create mode 100644 package-skills/comet-api-graphql/references/gen-04-args.md create mode 100644 package-skills/comet-api-graphql/references/gen-05-paginated.md create mode 100644 package-skills/comet-api-graphql/references/gen-06-nested-input.md create mode 100644 package-skills/comet-api-graphql/references/gen-07-service.md diff --git a/package-skills/comet-api-graphql/SKILL.md b/package-skills/comet-api-graphql/SKILL.md new file mode 100644 index 0000000000..84ddec3136 --- /dev/null +++ b/package-skills/comet-api-graphql/SKILL.md @@ -0,0 +1,188 @@ +--- +name: comet-api-graphql +description: | + Generates NestJS/GraphQL CRUD API files (service, thin resolver, input/filter/sort/args DTOs, paginated response) for a MikroORM entity in a Comet DXP project. Services contain all business logic; resolvers are thin GraphQL layers. Uses @comet/cms-api utilities for pagination, filtering, sorting, and permissions. + TRIGGER when: any work involves NestJS, MikroORM, or GraphQL entities in the api/ package — creating, modifying, or generating resolvers, services, DTOs, inputs, filters, sorts, args, entities, or modules. Also trigger when the user or another skill/agent describes a new entity, asks to generate API boilerplate, or adds/removes fields from an existing entity. +--- + +# Comet GraphQL API Skill + +Generate NestJS/GraphQL CRUD API files by analyzing a MikroORM entity (or entity description from the user) and producing service, resolver, and DTO files following the patterns in `references/`. + +## Input Sources + +The skill accepts two starting points: + +1. **Existing entity file** — Read the entity's MikroORM/GraphQL decorators (`@Property`, `@ManyToOne`, `@Enum`, `@RootBlock`, etc.) to derive all API files. +2. **User description** — The user describes what the entity looks like (fields, types, relations). If information is missing, **ask before generating**: + - For relations: What type? (`ManyToOne`, `OneToMany`, `ManyToMany`, `OneToOne`) + - For relations: Is the relation required or nullable? + - For OneToMany: Is it `orphanRemoval: true` (nested input) or just ID references? + - For the entity: Does it need position ordering? A slug field? + - What permission string to use for `@RequiredPermission`? + - Where should the files be written? + +## Prerequisites + +1. **Read the entity file** (if it exists) to extract: class name, all properties with their MikroORM/GraphQL decorators, and relation types. +2. **Determine scope mode** — Most entities in this project are scoped. If the entity file exists, read it to determine the mode from its decorators. If working from a user description and scope is not mentioned, **ask the user** before proceeding — do not assume no scope. + - (a) `@ScopedEntity` with callback → **preferred** — entity has flat scope fields (`domain`, `language`) and `@ScopedEntity((row) => ({ domain: row.domain, language: row.language }))` + - (b) no scope → use `skipScopeCheck: true` +3. **Identify the output directory** — ask the user or infer from project structure. DTOs go in a `dto/` subfolder. +4. **Check the NestJS module** file to understand existing providers and entity registrations. + +## Generation Workflow + +### Step 1 — Analyze the entity + +Extract from the entity file or user description: + +- Class name → derive name variants (see [gen-00-resolver.md](references/gen-00-resolver.md) for naming) +- All properties: type, MikroORM decorators (`@Property`, `@ManyToOne`, `@OneToMany`, `@ManyToMany`, `@OneToOne`, `@Enum`, `@RootBlock`), nullability +- Whether entity has: `position` field, `slug` field (unique), `@ScopedEntity` with flat scope fields +- Permission string for `@RequiredPermission` + +### Step 2 — Determine API mode and which files to generate + +**API mode** — Determine whether the entity needs full CRUD (read + write) or read-only access: + +- **Full CRUD** (default) — generates queries AND mutations (create, update, delete) +- **Read-only** — generates only queries (findOne, findAll) and `@ResolveField` methods. No mutations, no input DTOs. + +If the user says "read-only", "no mutations", "query only", or similar, use read-only mode. If unclear, ask. + +| File | Full CRUD | Read-only | Condition | +| ------------------ | ---------------------------------------- | --------- | ------------------------------------------------------- | +| Service | Yes | Yes | Read-only: only `findOneById`, `findAll` methods | +| Resolver | Yes | Yes | Read-only: only queries + `@ResolveField`, no mutations | +| Input DTO | Yes | No | — | +| Filter DTO | Yes | Yes | — | +| Sort DTO | Yes | Yes | — | +| Args DTO | Yes | Yes | — | +| Paginated Response | Yes | Yes | — | +| Scope Input DTO | When entity has `@ScopedEntity` (Mode A) | Same | See feature-05-scope.md | +| Nested Input | Per OneToMany with `orphanRemoval: true` | No | Only when relation should be in input | + +### Step 3 — Generate files + +**Always read [gen-07-service.md](references/gen-07-service.md) and [gen-00-resolver.md](references/gen-00-resolver.md) first** — the service contains all business logic, the resolver is a thin GraphQL layer. Then read the applicable files: + +#### Generated File Types + +| File Type | Reference | Description | +| ---------------------- | ----------------------------------------------------------- | ------------------------------------------------------- | +| **Service** (base) | [gen-07-service.md](references/gen-07-service.md) | All business logic: CRUD, relations, blocks, position | +| **Resolver** (thin) | [gen-00-resolver.md](references/gen-00-resolver.md) | Thin GraphQL layer: queries, mutations, @ResolveField | +| **Input DTO** | [gen-01-input.md](references/gen-01-input.md) | Create + Update input types with validators | +| **Filter DTO** | [gen-02-filter.md](references/gen-02-filter.md) | Filter input with and/or recursion | +| **Sort DTO** | [gen-03-sort.md](references/gen-03-sort.md) | Sort field enum + sort input | +| **Args DTO** | [gen-04-args.md](references/gen-04-args.md) | List query arguments (search, filter, sort, pagination) | +| **Paginated Response** | [gen-05-paginated.md](references/gen-05-paginated.md) | Paginated wrapper type | +| **Nested Input** | [gen-06-nested-input.md](references/gen-06-nested-input.md) | Input for OneToMany with orphanRemoval | + +#### Feature Overlays + +Read these when the entity has the corresponding feature. Apply changes on top of the base patterns: + +| Feature | Reference | When to apply | +| -------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| **Position ordering** | [feature-01-position.md](references/feature-01-position.md) | Entity has `position: number` field | +| **Validation** | [feature-02-validation.md](references/feature-02-validation.md) | Create/update operations need business logic validation | +| **Dedicated resolver arg** | [feature-03-dedicated-arg.md](references/feature-03-dedicated-arg.md) | A ManyToOne relation should be a top-level resolver arg instead of in the input | +| **Slug query** | [feature-04-slug.md](references/feature-04-slug.md) | Entity has a `slug` string field with `unique: true` | +| **Scoped entity** | [feature-05-scope.md](references/feature-05-scope.md) | Entity has `@ScopedEntity` with flat scope fields | + +#### Field Type Patterns + +Read the relevant field file when determining how to handle each property in input/filter/sort/service: + +| Field Type | Reference | When to use | +| ------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ | +| **Scalars** (string, number, boolean, date) | [field-01-scalar.md](references/field-01-scalar.md) | Primitive `@Property` fields | +| **Enums** | [field-02-enum.md](references/field-02-enum.md) | `@Enum()` fields (single or array) | +| **Relations** | [field-03-relation.md](references/field-03-relation.md) | `@ManyToOne`, `@OneToMany`, `@ManyToMany`, `@OneToOne` | +| **Blocks** | [field-04-block.md](references/field-04-block.md) | `@RootBlock()` CMS block fields | +| **JSON / Embedded** | [field-05-json-embedded.md](references/field-05-json-embedded.md) | JSON properties, `@Embedded()` objects | + +### Step 4 — Register permission in AppPermission enum + +The permission string used in `@RequiredPermission` must be registered in `api/src/auth/app-permission.enum.ts`. Add a new entry to the `AppPermission` enum for the entity's permission. Use camelCase for the enum key and the camelCase permission string as the value. + +Example — if the resolver uses `@RequiredPermission(["weatherStation"])`: + +```typescript +export enum AppPermission { + weatherStation = "weatherStation", +} +``` + +This enum is referenced by `UserPermissionsModule.forRootAsync()` in `api/src/app.module.ts` and controls which permissions are available in the admin UI. + +### Step 5 — Register in NestJS module + +Add the service and resolver to the module's `providers` array. Ensure all referenced entities are in `MikroOrmModule.forFeature([...])`. + +### Step 6 — Lint & schema refresh + +1. Run `npm --prefix api run lint:eslint -- --fix` from the project root. +2. Run `npm --prefix api run lint:tsc` to verify the API compiles without errors. +3. Verify the new entity's queries/mutations appear in the updated `schema.gql`. + +### Step 7 — Create database migration + +After creating or modifying an entity, a database migration must be created to apply the schema changes: + +1. Run `npm --prefix api run mikro-orm migration:create` from the project root. +2. This produces a new migration file in the `src/db/migrations/` folder. +3. **Important: Clean up the migration file.** The generated migration often contains migration steps unrelated to the current change (e.g., from other pending schema differences). Review the file and **remove all statements that are not a direct result of the entity change you just made**. Only keep the SQL statements that correspond to the fields/relations you added, removed, or modified. +4. Run `npm --prefix api run db:migrate` to execute the migration. +5. **The migration MUST succeed.** If the migration fails, investigate the error and attempt to fix it (e.g., correct the SQL in the migration file, fix entity definitions, resolve constraint issues). If you cannot resolve the issue after reasonable attempts, ask the user for guidance — do not skip or ignore a failing migration. + +## Relation Decision Rules + +When encountering a relation on an entity, use this table to determine which pattern to apply. The key question is: **Does the related entity have its own CRUD API (independently queryable/editable)?** This must be determined from the user's description, existing code, or by asking the user. + +| # | Relation Type | Has Own CRUD? | Input DTO Shape | Service Create | Service Update | ResolveField | Filter/Sort | Child Resolver | +| --- | --------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| R1 | **ManyToOne** (standard) | n/a — target is independent | `string` (UUID), `@Field(() => ID)`, `@IsUUID()`. Nullable: `@IsOptional()`, `nullable: true` | `Reference.create(await em.findOneOrFail(Target, id))` | `if (input !== undefined)` guard, then same. Nullable can unlink via `undefined` | `.loadOrFail()` or `?.loadOrFail()` | `ManyToOneFilter`, sortable | n/a | +| R2 | **ManyToOne** as dedicated arg | **Yes** — child has own CRUD, this ManyToOne points back to parent | **Excluded from input**. Becomes top-level `@Args("parent", { type: () => ID })` on child create. Required field in child list args. | `Reference.create(await em.findOneOrFail(Parent, parentArg))` | Parent FK not editable | `child.parent.loadOrFail()` | Required in args (`where.parent = id`). Excluded from filter/sort. `@AffectedEntity(Parent, { idArg })` | Full CRUD (see [feature-03](references/feature-03-dedicated-arg.md)) | +| R3 | **ManyToMany** (simple, no join entity) | n/a — ORM manages join table | `string[]` (UUID array), `@Field(() => [ID], { defaultValue: [] })`, `@IsArray()`, `@IsUUID(each)` | Find by IDs → validate count → `collection.loadItems()` → `collection.set(_.map(Reference.create))` | Same as create | `.loadItems()` | `ManyToManyFilter`. Not sortable | n/a | +| R4 | **ManyToMany** with join entity | **No** — join entity has no CRUD | `ParentNestedJoinEntityInput[]` — other-side FK as UUID `string` + extra scalar fields | Destructure FK → `findOneOrFail` → `em.assign(new JoinEntity(), { ...scalars, otherSide: Reference.create() })` via `collection.set()` | Same (full replacement, orphan removal) | `.loadItems()` | Not in filter/sort | Minimal: only `@ResolveField` for both FKs | +| R5 | **OneToMany** nested | **No** — child only exists as part of parent | `ParentNestedChildInput[]` — only scalar fields, no parent FK, no ID (see [gen-06](references/gen-06-nested-input.md)) | `collection.loadItems()` → `collection.set(inputs.map(i => em.assign(new Child(), { ...i })))` | Same (full replacement, orphan removal) | `.loadItems()` | Not in filter/sort | Minimal: only `@ResolveField` for back-reference | +| R6 | **OneToMany** separate CRUD | **Yes** — child is independently queryable/editable | **Excluded from parent input** entirely. Parent only gets `@ResolveField` | n/a on parent side | n/a on parent side | Parent: `.loadItems()` | n/a on parent side | Full CRUD. Child's ManyToOne to parent uses R2 | +| R7 | **OneToOne** nested | **No** — related entity only exists with parent | Nested object with scalar fields, no FK, no ID. Nullable: `@IsOptional()` | `new Child()` + `em.assign(child, { ...input })` | `parent.rel ? await parent.rel.loadOrFail() : new Child()` | `?.loadOrFail()` | Not in filter/sort | None needed | + +### How to decide "Has Own CRUD" vs "Nested" + +| Signal | Own CRUD (R2/R6) | Nested (R4/R5/R7) | +| --------------------------------- | ---------------------------------- | -------------------------------------- | +| Independently queryable? | Yes — has own list page, own forms | No — only meaningful as part of parent | +| Has `position` grouped by parent? | Often yes | Rarely | +| Uses `orphanRemoval: true`? | No | Yes | +| Fully replaced on parent save? | No | Yes | + +When the user describes a relation and it's unclear whether the child should be managed independently or inline with the parent, **always ask the user** before proceeding. + +## Key Rules + +### Architecture: Thin Resolvers, Service-based Business Logic + +- **Always generate a service** (`{entity-names}.service.ts`) — it is the single source of business logic. +- **Resolvers MUST be thin** — they handle GraphQL concerns (decorators, argument parsing, `@ResolveField`) and delegate all CRUD operations to the service. +- **Resolvers MUST NOT inject `EntityManager`** — only inject the service. `EntityManager` belongs exclusively in services for testability: services can be unit-tested with a mocked `EntityManager`, while resolvers stay free of data-access concerns. +- **EVERY relation and block MUST have a `@ResolveField`** — no exceptions. Every `@ManyToOne`, `@OneToMany`, `@ManyToMany`, and `@RootBlock` on the entity requires a corresponding `@ResolveField` method in the resolver. Never eagerly load relations via `.init()` or hardcoded `populate` in queries — use `@ResolveField` with lazy loading instead. +- **`@ResolveField` for relations** stays in the resolver: `entity.relation.loadOrFail()` (ManyToOne) or `entity.relation.loadItems()` (ManyToMany/OneToMany). +- **`@ResolveField` for blocks** delegates to the service (`this.service.transformToPlain(entity.blockField)`). +- **Service constructor** injects `EntityManager` and optionally `BlocksTransformerService` (for blocks). + +### DTOs and Input + +- **Import `PartialType` from `@comet/cms-api`**, NOT from `@nestjs/graphql`. +- **Destructure** relation and block fields from input before spreading in the **service**: `const { relation: relationInput, ...assignInput } = input`. +- **Always add `and`/`or` fields** to filter DTOs for recursive filtering. +- **Default sort**: `position ASC` if entity has position, otherwise `createdAt ASC`. +- **Populate array**: Only populate relations that have a `@ResolveField`. The resolver extracts GraphQL fields via `extractGraphqlFields` and passes them to the service's `findAll` method. +- **UpdateInput**: Always `extends PartialType(EntityInput)` — nothing else needed. +- **Nullable ManyToOne in update**: Check `if (relationInput !== undefined)` in the **service** to distinguish "not provided" from "set to null". +- When entity has no relations with `@ResolveField`, omit `@Info()`, `extractGraphqlFields`, and `fields` parameter from resolver and service. +- `requiredPermission` as string: `@RequiredPermission("x", ...)`. As array: `@RequiredPermission(["x"], ...)`. diff --git a/package-skills/comet-api-graphql/references/feature-01-position.md b/package-skills/comet-api-graphql/references/feature-01-position.md new file mode 100644 index 0000000000..1c06f413a3 --- /dev/null +++ b/package-skills/comet-api-graphql/references/feature-01-position.md @@ -0,0 +1,257 @@ +# Feature: Position Ordering + +Apply when entity has a `position: number` field. Position can be global (no grouping) or grouped by some fields (e.g. variants ordered within a product, ordered products within scope). + +Position logic is handled entirely in the **service**. + +## Service Changes + +The CRUD service includes private position helper methods and integrates position management into the CRUD methods. + +### Without groupByFields (global ordering) + +```typescript +import { gqlArgsToMikroOrmQuery, gqlSortToMikroOrmOrderBy } from "@comet/cms-api"; +import { EntityManager, FindOptions, raw } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; + +import { PaginatedProductCategories } from "./dto/paginated-product-categories"; +import { ProductCategoryInput, ProductCategoryUpdateInput } from "./dto/product-category.input"; +import { ProductCategoriesArgs } from "./dto/product-categories.args"; +import { ProductCategory } from "./entities/product-category.entity"; + +@Injectable() +export class ProductCategoriesService { + constructor(private readonly entityManager: EntityManager) {} + + async findOneById(id: string): Promise { + return this.entityManager.findOneOrFail(ProductCategory, id); + } + + async findAll({ search, filter, sort, offset, limit }: ProductCategoriesArgs): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(ProductCategory)); + const options: FindOptions = { offset, limit }; + if (sort) { + options.orderBy = gqlSortToMikroOrmOrderBy(sort); + } + const [entities, totalCount] = await this.entityManager.findAndCount(ProductCategory, where, options); + return new PaginatedProductCategories(entities, totalCount); + } + + async create(input: ProductCategoryInput): Promise { + const lastPosition = await this.getLastPosition(); + let position = input.position; + if (position !== undefined && position < lastPosition + 1) { + await this.incrementPositions(position); + } else { + position = lastPosition + 1; + } + + const productCategory = this.entityManager.create(ProductCategory, { ...input, position }); + await this.entityManager.flush(); + return productCategory; + } + + async update(id: string, input: ProductCategoryUpdateInput): Promise { + const productCategory = await this.entityManager.findOneOrFail(ProductCategory, id); + + if (input.position !== undefined) { + const lastPosition = await this.getLastPosition(); + if (input.position > lastPosition) { + input.position = lastPosition; + } + if (productCategory.position < input.position) { + await this.decrementPositions(productCategory.position, input.position); + } else if (productCategory.position > input.position) { + await this.incrementPositions(input.position, productCategory.position); + } + } + + productCategory.assign({ ...input }); + await this.entityManager.flush(); + return productCategory; + } + + async delete(id: string): Promise { + const productCategory = await this.entityManager.findOneOrFail(ProductCategory, id); + this.entityManager.remove(productCategory); + await this.decrementPositions(productCategory.position); + await this.entityManager.flush(); + return true; + } + + private async incrementPositions(lowestPosition: number, highestPosition?: number) { + await this.entityManager.nativeUpdate( + ProductCategory, + { position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } }, + { position: raw("position + 1") }, + ); + } + + private async decrementPositions(lowestPosition: number, highestPosition?: number) { + await this.entityManager.nativeUpdate( + ProductCategory, + { position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } }, + { position: raw("position - 1") }, + ); + } + + private async getLastPosition() { + return this.entityManager.count(ProductCategory, {}); + } +} +``` + +### With groupByFields (e.g. `position: { groupByFields: ["product"] }`) + +Add a `group` parameter to position helpers and CRUD methods: + +```typescript +@Injectable() +export class ProductVariantsService { + constructor(private readonly entityManager: EntityManager) {} + + // ... findOneById, findAll same as base ... + + async create(product: string, input: ProductVariantInput): Promise { + const lastPosition = await this.getLastPosition({ product }); + let position = input.position; + if (position !== undefined && position < lastPosition + 1) { + await this.incrementPositions({ product }, position); + } else { + position = lastPosition + 1; + } + + const productVariant = this.entityManager.create(ProductVariant, { + ...input, + position, + product: Reference.create(await this.entityManager.findOneOrFail(Product, product)), + }); + await this.entityManager.flush(); + return productVariant; + } + + async update(id: string, input: ProductVariantUpdateInput): Promise { + const productVariant = await this.entityManager.findOneOrFail(ProductVariant, id); + const group = { product: productVariant.product.id }; + + if (input.position !== undefined) { + const lastPosition = await this.getLastPosition(group); + if (input.position > lastPosition) { + input.position = lastPosition; + } + if (productVariant.position < input.position) { + await this.decrementPositions(group, productVariant.position, input.position); + } else if (productVariant.position > input.position) { + await this.incrementPositions(group, input.position, productVariant.position); + } + } + + productVariant.assign({ ...input }); + await this.entityManager.flush(); + return productVariant; + } + + async delete(id: string): Promise { + const productVariant = await this.entityManager.findOneOrFail(ProductVariant, id); + const group = { product: productVariant.product.id }; + this.entityManager.remove(productVariant); + await this.decrementPositions(group, productVariant.position); + await this.entityManager.flush(); + return true; + } + + private async incrementPositions(group: { product: string }, lowestPosition: number, highestPosition?: number) { + await this.entityManager.nativeUpdate( + ProductVariant, + { + $and: [ + { position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } }, + this.getPositionGroupCondition(group), + ], + }, + { position: raw("position + 1") }, + ); + } + + private async decrementPositions(group: { product: string }, lowestPosition: number, highestPosition?: number) { + await this.entityManager.nativeUpdate( + ProductVariant, + { + $and: [ + { position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } }, + this.getPositionGroupCondition(group), + ], + }, + { position: raw("position - 1") }, + ); + } + + private async getLastPosition(group: { product: string }) { + return this.entityManager.count(ProductVariant, this.getPositionGroupCondition(group)); + } + + private getPositionGroupCondition(group: { product: string }): FilterQuery { + return { product: group.product }; + } +} +``` + +## Resolver Changes + +The resolver stays thin — no position logic. It simply delegates to service methods: + +```typescript +@Resolver(() => ProductCategory) +@RequiredPermission(["productCategories"], { skipScopeCheck: true }) +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): Promise { + return this.productCategoriesService.findAll(args); + } + + @Mutation(() => ProductCategory) + async createProductCategory(@Args("input", { type: () => ProductCategoryInput }) input: ProductCategoryInput): Promise { + return this.productCategoriesService.create(input); + } + + @Mutation(() => ProductCategory) + @AffectedEntity(ProductCategory) + async updateProductCategory( + @Args("id", { type: () => ID }) id: string, + @Args("input", { type: () => ProductCategoryUpdateInput }) input: ProductCategoryUpdateInput, + ): Promise { + return this.productCategoriesService.update(id, input); + } + + @Mutation(() => Boolean) + @AffectedEntity(ProductCategory) + async deleteProductCategory(@Args("id", { type: () => ID }) id: string): Promise { + return this.productCategoriesService.delete(id); + } +} +``` + +## Input changes + +Position is optional in input with `@Min(1)`: + +```typescript +@IsOptional() +@Min(1) +@IsInt() +@Field(() => Int, { nullable: true }) +position?: number; +``` + +## Default sort + +Change default sort in Args to `position ASC` instead of `createdAt ASC`. diff --git a/package-skills/comet-api-graphql/references/feature-02-validation.md b/package-skills/comet-api-graphql/references/feature-02-validation.md new file mode 100644 index 0000000000..43fb94a17c --- /dev/null +++ b/package-skills/comet-api-graphql/references/feature-02-validation.md @@ -0,0 +1,215 @@ +# Feature: Validation + +Apply when create/update operations need business logic validation beyond what DTO decorators (`@IsNotEmpty`, `@IsUUID`, etc.) can express. Examples: uniqueness checks, cross-field constraints, permission-based restrictions, or external service lookups. + +## What changes + +1. Service defines a `{EntityName}ValidationError` ObjectType and private validation methods +2. Service `create`/`update` methods call validation before persisting and return **payload objects** (entity + errors) instead of the entity directly +3. Resolver returns **payload types** instead of the entity for create/update mutations +4. Resolver injects `@GetCurrentUser() user: CurrentUser` and passes it to the service + +## ValidationError ObjectType (in service file) + +Define the error type in the service file alongside the service class: + +```typescript +import { Field, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +export class ProductVariantValidationError { + @Field({ nullable: true }) + field?: string; + + @Field() + code: string; +} +``` + +> This follows the `ValidationError` interface shape from `@comet/cms-api` (`{ field?: string; code: string }`) but as a GraphQL ObjectType. + +## Service — validation methods and CRUD changes + +Validation methods are `private` in the CRUD service. They return an array of errors (empty = valid). + +```typescript +import { CurrentUser } from "@comet/cms-api"; +import { EntityManager } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; +import { Field, ObjectType } from "@nestjs/graphql"; + +import { ProductVariantInput, ProductVariantUpdateInput } from "./dto/product-variant.input"; +import { ProductVariant } from "./entities/product-variant.entity"; + +@ObjectType() +export class ProductVariantValidationError { + @Field({ nullable: true }) + field?: string; + + @Field() + code: string; +} + +@Injectable() +export class ProductVariantsService { + constructor(private readonly entityManager: EntityManager) {} + + // ... findOneById, findAll, delete same as base pattern ... + + async create( + input: ProductVariantInput, + user: CurrentUser, + ): Promise<{ productVariant?: ProductVariant; errors: ProductVariantValidationError[] }> { + const errors = await this.validateCreateInput(input, { currentUser: user }); + if (errors.length > 0) { + return { errors }; + } + + const productVariant = this.entityManager.create(ProductVariant, { ...input }); + await this.entityManager.flush(); + return { productVariant, errors: [] }; + } + + async update( + id: string, + input: ProductVariantUpdateInput, + user: CurrentUser, + ): Promise<{ productVariant?: ProductVariant; errors: ProductVariantValidationError[] }> { + const productVariant = await this.entityManager.findOneOrFail(ProductVariant, id); + + const errors = await this.validateUpdateInput(input, { currentUser: user, entity: productVariant }); + if (errors.length > 0) { + return { errors }; + } + + productVariant.assign({ ...input }); + await this.entityManager.flush(); + return { productVariant, errors: [] }; + } + + private async validateCreateInput(input: ProductVariantInput, context: { currentUser: CurrentUser }): Promise { + const errors: ProductVariantValidationError[] = []; + + // Example: uniqueness check + const existing = await this.entityManager.findOne(ProductVariant, { sku: input.sku }); + if (existing) { + errors.push({ field: "sku", code: "SKU_ALREADY_EXISTS" }); + } + + // Example: cross-field constraint + if (input.minQuantity && input.maxQuantity && input.minQuantity > input.maxQuantity) { + errors.push({ field: "minQuantity", code: "MIN_EXCEEDS_MAX" }); + } + + return errors; + } + + private async validateUpdateInput( + input: ProductVariantUpdateInput, + context: { currentUser: CurrentUser; entity: ProductVariant }, + ): Promise { + const errors: ProductVariantValidationError[] = []; + + // Example: uniqueness check (exclude current entity) + if (input.sku !== undefined) { + const existing = await this.entityManager.findOne(ProductVariant, { + sku: input.sku, + id: { $ne: context.entity.id }, + }); + if (existing) { + errors.push({ field: "sku", code: "SKU_ALREADY_EXISTS" }); + } + } + + return errors; + } +} +``` + +### With dedicatedResolverArg + +When the entity has a dedicated resolver arg, pass it through to validation: + +```typescript +async create( + product: string, + input: ProductVariantInput, + user: CurrentUser, +): Promise<{ productVariant?: ProductVariant; errors: ProductVariantValidationError[] }> { + const errors = await this.validateCreateInput(input, { currentUser: user, args: { product } }); + if (errors.length > 0) { + return { errors }; + } + // ... create with product reference ... +} + +private async validateCreateInput( + input: ProductVariantInput, + context: { currentUser: CurrentUser; args?: { product: string } }, +): Promise { + // Can access context.args.product for parent-scoped validation +} +``` + +## Payload Types (in resolver file) + +Payload types are GraphQL ObjectTypes and belong in the resolver file: + +```typescript +import { Field, ObjectType } from "@nestjs/graphql"; +import { ProductVariantValidationError } from "./product-variants.service"; +import { ProductVariant } from "./entities/product-variant.entity"; + +@ObjectType() +class CreateProductVariantPayload { + @Field(() => ProductVariant, { nullable: true }) + productVariant?: ProductVariant; + + @Field(() => [ProductVariantValidationError], { nullable: false }) + errors: ProductVariantValidationError[]; +} + +@ObjectType() +class UpdateProductVariantPayload { + @Field(() => ProductVariant, { nullable: true }) + productVariant?: ProductVariant; + + @Field(() => [ProductVariantValidationError], { nullable: false }) + errors: ProductVariantValidationError[]; +} +``` + +## Resolver — thin, passes user to service + +```typescript +import { CurrentUser, GetCurrentUser } from "@comet/cms-api"; + +@Mutation(() => CreateProductVariantPayload) +async createProductVariant( + @Args("input", { type: () => ProductVariantInput }) + input: ProductVariantInput, + @GetCurrentUser() + user: CurrentUser, +): Promise { + return this.productVariantsService.create(input, user); +} + +@Mutation(() => UpdateProductVariantPayload) +@AffectedEntity(ProductVariant) +async updateProductVariant( + @Args("id", { type: () => ID }) id: string, + @Args("input", { type: () => ProductVariantUpdateInput }) input: ProductVariantUpdateInput, + @GetCurrentUser() user: CurrentUser, +): Promise { + return this.productVariantsService.update(id, input, user); +} +``` + +## Rules + +- validation logic lives directly in the CRUD service as `private` methods. +- **ValidationError ObjectType** is defined in the service file and exported for the resolver to import. +- **Payload types** are defined in the resolver file (they're GraphQL return types). +- **`CurrentUser`** is extracted via `@GetCurrentUser()` in the resolver and passed to the service — the service never uses GraphQL decorators. +- Only apply this pattern when business logic validation is needed. Standard DTO validation (`@IsNotEmpty`, `@IsUUID`, `@Min`, etc.) covers most cases. +- The `delete` method is not affected — it stays the same (no validation payload needed, just returns `boolean`). diff --git a/package-skills/comet-api-graphql/references/feature-03-dedicated-arg.md b/package-skills/comet-api-graphql/references/feature-03-dedicated-arg.md new file mode 100644 index 0000000000..7a1efbe479 --- /dev/null +++ b/package-skills/comet-api-graphql/references/feature-03-dedicated-arg.md @@ -0,0 +1,104 @@ +# Feature: Dedicated Resolver Arg + +Apply when a `@ManyToOne` relation should be a top-level resolver argument instead of part of the input DTO. Typical for child entities scoped to a parent (e.g. variants of a product). + +This moves the parent ID from the input DTO to a top-level resolver argument. Used for child entities scoped to a parent (e.g. `ProductVariant` belongs to `Product`). + +## What changes + +1. Parent field is **excluded from input DTO** (not in `ProductVariantInput`) +2. Parent becomes a **top-level `@Args`** in create mutation and list query +3. **Args DTO** gets the parent as a required field +4. **@AffectedEntity** uses parent entity on list query and create mutation +5. **Service** `create` method accepts parentId as a parameter +6. **Service** `findAll` method filters by parent (from args) + +## Args DTO changes + +```typescript +@ArgsType() +export class ProductVariantsArgs extends OffsetBasedPaginationArgs { + @Field(() => ID) + @IsUUID() + product: string; // dedicated arg — required, before search/filter/sort + + @Field({ nullable: true }) + @IsOptional() + @IsString() + search?: string; + + // ... filter, sort as usual +} +``` + +## Service changes + +### findAll — filters by parent + +```typescript +async findAll({ product, search, filter, sort, offset, limit }: ProductVariantsArgs, fields?: string[]): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(ProductVariant)); + where.product = product; // filter by parent + // ... rest as usual (options, populate, findAndCount) +} +``` + +### create — accepts parentId + +```typescript +async create(product: string, input: ProductVariantInput): Promise { + // ... destructure input ... + const productVariant = this.entityManager.create(ProductVariant, { + ...assignInput, + product: Reference.create(await this.entityManager.findOneOrFail(Product, product)), + }); + await this.entityManager.flush(); + return productVariant; +} +``` + +## Resolver changes + +The resolver stays thin — it passes the dedicated arg to the service: + +### List Query + +```typescript +@Query(() => PaginatedProductVariants) +@AffectedEntity(Product, { idArg: "product" }) +async productVariants( + @Args() args: ProductVariantsArgs, + @Info() info: GraphQLResolveInfo, +): Promise { + const fields = extractGraphqlFields(info, { root: "nodes" }); + return this.productVariantsService.findAll(args, fields); +} +``` + +### Create Mutation + +```typescript +@Mutation(() => ProductVariant) +@AffectedEntity(Product, { idArg: "product" }) +async createProductVariant( + @Args("product", { type: () => ID }) + product: string, + @Args("input", { type: () => ProductVariantInput }) + input: ProductVariantInput, +): Promise { + return this.productVariantsService.create(product, input); +} +``` + +## @ResolveField + +The relation still gets a `@ResolveField`: + +```typescript +@ResolveField(() => Product) +async product( + @Parent() productVariant: ProductVariant, +): Promise { + return productVariant.product.loadOrFail(); +} +``` diff --git a/package-skills/comet-api-graphql/references/feature-04-slug.md b/package-skills/comet-api-graphql/references/feature-04-slug.md new file mode 100644 index 0000000000..2173af89bc --- /dev/null +++ b/package-skills/comet-api-graphql/references/feature-04-slug.md @@ -0,0 +1,68 @@ +# Feature: Slug Query + +Apply when entity has a `slug` string field with `unique: true`. + +## Service — findBySlug method + +Add a `findBySlug` method to the service: + +```typescript +async findBySlug(slug: string): Promise { + const productCategory = await this.entityManager.findOne(ProductCategory, { slug }); + return productCategory ?? null; +} +``` + +When the entity is scoped (Mode B), also accept scope: + +```typescript +async findBySlug(scope: NewsScope, slug: string): Promise { + const news = await this.entityManager.findOne(News, { slug, scope }); + return news ?? null; +} +``` + +## Resolver — additional query + +The resolver delegates to the service: + +```typescript +@Query(() => ProductCategory, { nullable: true }) +async productCategoryBySlug( + @Args("slug") + slug: string, +): Promise { + return this.productCategoriesService.findBySlug(slug); +} +``` + +When scoped: + +```typescript +@Query(() => News, { nullable: true }) +async newsBySlug( + @Args("scope", { type: () => NewsScope }) scope: NewsScope, + @Args("slug") slug: string, +): Promise { + return this.newsService.findBySlug(scope, slug); +} +``` + +## Input Validator + +Use `@IsSlug()` from `@comet/cms-api` instead of `@IsString()`: + +```typescript +import { IsSlug } from "@comet/cms-api"; + +@IsNotEmpty() +@IsSlug() +@Field() +slug: string; +``` + +## Rules + +- The slug query uses `findOne` (not `findOneOrFail`) and returns `null` if not found. +- No `@AffectedEntity` on the slug query. +- If the entity is scoped, the slug query should also accept and filter by scope. diff --git a/package-skills/comet-api-graphql/references/feature-05-scope.md b/package-skills/comet-api-graphql/references/feature-05-scope.md new file mode 100644 index 0000000000..3509d7c358 --- /dev/null +++ b/package-skills/comet-api-graphql/references/feature-05-scope.md @@ -0,0 +1,287 @@ +# Feature: Scoped Entities + +Two scope modes. **Most entities are scoped** — use `@ScopedEntity` with a callback that returns the scope from the entity's flat fields. + +## Mode A — @ScopedEntity (preferred) + +Entity has scope fields as **flat properties** (e.g. `domain`, `language`) directly on the entity, and uses the `@ScopedEntity` decorator with a callback that returns the scope object. The callback receives the entity instance and must return an object matching the project's `ContentScope` shape. The framework uses this for permission checks. + +### When to use + +Use `@ScopedEntity` when: + +- The entity is scoped (most entities in this project are) +- The entity has scope fields like `domain` and `language` as flat `@Property` fields + +### Scope Input DTO file — `dto/{entity-name}-scope.input.ts` + +A plain InputType class used for passing scope in GraphQL args. This is only a GraphQL input/output type. + +```typescript +import { Field, InputType, ObjectType } from "@nestjs/graphql"; +import { IsString } from "class-validator"; + +@ObjectType() +@InputType("NewsScopeInput") +export class NewsScope { + @Field() + @IsString() + domain: string; + + @Field() + @IsString() + language: string; +} +``` + +> **Naming**: `{EntityName}Scope` for the class, `"{EntityName}ScopeInput"` for the `@InputType` alias. +> **Fields**: Match the project's `ContentScope` shape (typically `domain` + `language`). Check `app.module.ts` for the `ContentScope` declaration. + +### Entity — flat scope fields with @ScopedEntity callback + +Scope fields are regular `@Property` / `@Field` on the entity. The `@ScopedEntity` decorator takes a callback `(entity) => scope` that extracts the content scope from the entity's flat fields. + +```typescript +import { ScopedEntity } from "@comet/cms-api"; +import { BaseEntity, Entity, OptionalProps, PrimaryKey, Property } from "@mikro-orm/postgresql"; +import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { IsString } from "class-validator"; +import { v4 as uuid } from "uuid"; + +@Entity() +@ObjectType() +@ScopedEntity((news) => ({ + domain: news.domain, + language: news.language, +})) +export class News extends BaseEntity { + [OptionalProps]?: "createdAt" | "updatedAt"; + + @PrimaryKey({ type: "uuid" }) + @Field(() => ID) + id: string = uuid(); + + @Property({ type: "text" }) + @Field() + @IsString() + domain: string; + + @Property({ type: "text" }) + @Field() + @IsString() + language: string; + + // ... other fields + + @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(); +} +``` + +### @RequiredPermission — NO skipScopeCheck + +```typescript +@RequiredPermission(["news"]) +``` + +### Args DTO — add scope as required first field + +```typescript +import { NewsScope } from "./news-scope.input"; + +@ArgsType() +export class NewsListArgs extends OffsetBasedPaginationArgs { + @Field(() => NewsScope) + @ValidateNested() + @Type(() => NewsScope) + scope: NewsScope; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + search?: string; + // ... +} +``` + +### Service — spread flat scope fields in findAll and create + +```typescript +@Injectable() +export class NewsService { + constructor(private readonly entityManager: EntityManager) {} + + async findOneById(id: string): Promise { + return this.entityManager.findOneOrFail(News, id); + } + + async findAll({ scope, search, filter, sort, offset, limit }: NewsListArgs): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(News)); + Object.assign(where, scope); // spread flat scope fields into where + const options: FindOptions = { offset, limit }; + if (sort) { + options.orderBy = gqlSortToMikroOrmOrderBy(sort); + } + const [entities, totalCount] = await this.entityManager.findAndCount(News, where, options); + return new PaginatedNews(entities, totalCount); + } + + async create(scope: NewsScope, input: NewsInput): Promise { + const news = this.entityManager.create(News, { + ...input, + ...scope, // spread flat scope fields + }); + await this.entityManager.flush(); + return news; + } + + async update(id: string, input: NewsUpdateInput): Promise { + const news = await this.entityManager.findOneOrFail(News, id); + news.assign({ ...input }); + await this.entityManager.flush(); + return news; + } + + async delete(id: string): Promise { + const news = await this.entityManager.findOneOrFail(News, id); + this.entityManager.remove(news); + await this.entityManager.flush(); + return true; + } +} +``` + +### Resolver — thin, passes scope to service + +```typescript +@Resolver(() => News) +@RequiredPermission(["news"]) +export class NewsResolver { + constructor(private readonly newsService: NewsService) {} + + @Query(() => News) + @AffectedEntity(News) + async news(@Args("id", { type: () => ID }) id: string): Promise { + return this.newsService.findOneById(id); + } + + @Query(() => PaginatedNews) + async newsList(@Args() args: NewsListArgs): Promise { + return this.newsService.findAll(args); + } + + @Mutation(() => News) + async createNews( + @Args("scope", { type: () => NewsScope }) scope: NewsScope, + @Args("input", { type: () => NewsInput }) input: NewsInput, + ): Promise { + return this.newsService.create(scope, input); + } + + @Mutation(() => News) + @AffectedEntity(News) + async updateNews( + @Args("id", { type: () => ID }) id: string, + @Args("input", { type: () => NewsUpdateInput }) input: NewsUpdateInput, + ): Promise { + return this.newsService.update(id, input); + } + + @Mutation(() => Boolean) + @AffectedEntity(News) + async deleteNews(@Args("id", { type: () => ID }) id: string): Promise { + return this.newsService.delete(id); + } +} +``` + +### Slug query — filter by flat scope fields + +```typescript +// In service: +async findBySlug(scope: NewsScope, slug: string): Promise { + const news = await this.entityManager.findOne(News, { slug, ...scope }); + return news ?? null; +} + +// In resolver: +@Query(() => News, { nullable: true }) +async newsBySlug( + @Args("scope", { type: () => NewsScope }) scope: NewsScope, + @Args("slug") slug: string, +): Promise { + return this.newsService.findBySlug(scope, slug); +} +``` + +## Mode A2 — @ScopedEntity via parent relation (sub-entities) + +When a sub-entity derives its scope from a parent entity (e.g. `ProductVariant` → `Product`), the entity does **not** have flat scope fields. Instead, the `@ScopedEntity` callback must load the parent relation to access the scope. The callback **must be async** because `Ref` requires `loadOrFail()` — using `getEntity()` will throw `"Reference not initialized"`. + +### Entity — async @ScopedEntity with loadOrFail + +```typescript +import { ScopedEntity } from "@comet/cms-api"; +import { BaseEntity, Entity, ManyToOne, PrimaryKey, Property, Ref } from "@mikro-orm/postgresql"; +import { Field, ID, ObjectType } from "@nestjs/graphql"; +import { Product } from "@src/products/entities/product.entity"; +import { v4 as uuid } from "uuid"; + +@Entity() +@ObjectType() +@ScopedEntity(async (productVariant) => { + const product = await productVariant.product.loadOrFail(); + return { domain: product.domain, language: product.language }; +}) +export class ProductVariant extends BaseEntity { + @PrimaryKey({ type: "uuid" }) + @Field(() => ID) + id: string = uuid(); + + @ManyToOne(() => Product, { ref: true }) + @Field(() => Product) + product: Ref; + + // ... other fields (no domain/language on this entity) +} +``` + +> **IMPORTANT:** Never use `getEntity()` in a `@ScopedEntity` callback on a `Ref` relation — it only works when the reference is already loaded in the identity map, which is not guaranteed (especially after `em.create()` + `em.flush()`). Always use the async pattern with `loadOrFail()`. + +### Key differences from Mode A + +| Aspect | Mode A (flat scope) | Mode A2 (parent scope) | +| ------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------ | +| Scope fields on entity | Yes (`domain`, `language`) | No — derived from parent | +| `@ScopedEntity` callback | Sync: `(e) => ({ domain: e.domain, ... })` | **Async**: `async (e) => { const p = await e.parent.loadOrFail(); return { ... }; }` | +| Scope DTO | Has own scope input | No scope input — uses parent's scope via dedicated arg (Rule R2) | +| Service create | Spreads `...scope` | Uses `Reference.create(parentEntity)` | +| Args | `scope` as first field | `parent` ID as first field (see [feature-03](feature-03-dedicated-arg.md)) | + +## Mode B — No scope + +Entity has no scope fields and no `@ScopedEntity`. + +```typescript +@RequiredPermission(["products"], { skipScopeCheck: true }) +``` + +No further changes needed. Service and resolver follow the base patterns. + +## Comparison table + +| Aspect | Mode A (@ScopedEntity flat) | Mode A2 (@ScopedEntity via parent) | Mode B (no scope) | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------- | +| Entity | `@ScopedEntity((row) => ({ domain: row.domain, language: row.language }))` + flat scope fields as `@Property` | `@ScopedEntity(async (row) => { const p = await row.parent.loadOrFail(); return { ... }; })` | No scope fields, no decorator | +| When to use | Entity is scoped (most entities) | Sub-entity deriving scope from parent relation | Entity needs no scope | +| Scope DTO | Generates `{entity}-scope.input.ts` (plain InputType) | None — uses parent's scope | None | +| Args | `scope: ScopeInput` as first field | `parent` ID as first field (dedicated arg) | Standard args | +| Service findAll | `Object.assign(where, scope)` — spreads flat scope fields | `where.parent = parentId` | Standard where | +| Service create | Accepts `scope` param, spreads into create via `...scope` | `Reference.create(parentEntity)` | Standard create | +| @AffectedEntity on list/create | Not needed on list | `@AffectedEntity(Parent, { idArg })` on list + create | Not needed | +| @RequiredPermission | Permission of entity itself, NO `skipScopeCheck` | Shares parent's permission, NO `skipScopeCheck` | `skipScopeCheck: true` | diff --git a/package-skills/comet-api-graphql/references/field-01-scalar.md b/package-skills/comet-api-graphql/references/field-01-scalar.md new file mode 100644 index 0000000000..475fae776d --- /dev/null +++ b/package-skills/comet-api-graphql/references/field-01-scalar.md @@ -0,0 +1,188 @@ +# Field Type: Scalars + +## String + +### Input + +```typescript +// Required — with length constraint from @Property({ length: 120 }) +@IsNotEmpty() +@IsString() +@MaxLength(120) +@Field() +title: string; + +// Required — text column (no length limit), no @MaxLength needed +@IsNotEmpty() +@IsString() +@Field() +body: string; + +// Optional / nullable +@IsOptional() +@IsString() +@MaxLength(200) +@Field({ nullable: true }) +description?: string; +``` + +**`@MaxLength` rule**: If the entity has `@Property({ length: N })`, always add `@MaxLength(N)` to the input. Omit `@MaxLength` for `text` columns (no length constraint). + +### Filter + +```typescript +@Field(() => StringFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => StringFilter) +title?: StringFilter; +``` + +### Sort + +Include in sort enum: `title = "title"`. + +### Search + +String fields are searchable by default. They are matched via the `search` arg using LIKE queries. + +--- + +## Number (Int / Float) + +### Input + +```typescript +// Int - required +@IsNotEmpty() +@IsInt() +@Field(() => Int) +quantity: number; + +// Float - required +@IsNotEmpty() +@IsNumber() +@Field(() => Float) +price: number; + +// Optional +@IsOptional() +@IsInt() +@Field(() => Int, { nullable: true }) +stock?: number; +``` + +### Filter + +```typescript +@Field(() => NumberFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => NumberFilter) +quantity?: NumberFilter; +``` + +### Sort + +Include in sort enum: `quantity = "quantity"`. + +--- + +## Boolean + +### Input + +```typescript +// Required +@IsNotEmpty() +@IsBoolean() +@Field() +isActive: boolean; + +// With default value +@IsNotEmpty() +@IsBoolean() +@Field({ defaultValue: true }) +isActive: boolean; +``` + +### Filter + +```typescript +@Field(() => BooleanFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => BooleanFilter) +isActive?: BooleanFilter; +``` + +### Sort + +Include in sort enum: `isActive = "isActive"`. + +--- + +## Date / DateTime + +### Input + +```typescript +// DateTime (Date type in entity) +@IsNotEmpty() +@IsDate() +@Field() +publishedAt: Date; + +// Optional +@IsOptional() +@IsDate() +@Field({ nullable: true }) +publishedAt?: Date; +``` + +### Filter + +```typescript +// DateTime (JS Date) +@Field(() => DateTimeFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => DateTimeFilter) +publishedAt?: DateTimeFilter; + +// LocalDate (date-only, uses GraphQLLocalDate scalar) +@Field(() => DateFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => DateFilter) +eventDate?: DateFilter; +``` + +### Sort + +Include in sort enum: `publishedAt = "publishedAt"`. + +--- + +## Timestamps (`createdAt` / `updatedAt`) + +- **Exclude** from input (auto-managed by MikroORM). +- **Include** in filter (DateTimeFilter). +- **Include** in sort enum. + +--- + +## Semantic Validators + +When a field has a well-known format, prefer a semantic validator from `class-validator` over the generic `@IsString()` / `@IsNumber()`: + +| Field meaning | Validator | Instead of | +| ------------------- | ----------------------------------- | ------------- | +| Latitude | `@IsLatitude()` | `@IsNumber()` | +| Longitude | `@IsLongitude()` | `@IsNumber()` | +| Email | `@IsEmail()` | `@IsString()` | +| URL | `@IsUrl()` | `@IsString()` | +| UUID (non-relation) | `@IsUUID()` | `@IsString()` | +| Slug | `@IsSlug()` (from `@comet/cms-api`) | `@IsString()` | + +These validators are used **in addition to** `@IsNotEmpty()` / `@IsOptional()`, replacing the generic type validator. diff --git a/package-skills/comet-api-graphql/references/field-02-enum.md b/package-skills/comet-api-graphql/references/field-02-enum.md new file mode 100644 index 0000000000..ea6a38ac2b --- /dev/null +++ b/package-skills/comet-api-graphql/references/field-02-enum.md @@ -0,0 +1,83 @@ +# Field Type: Enums + +## Single Enum + +### Input + +```typescript +import { ProductType } from "../entities/product.entity"; // or separate enum file + +@IsNotEmpty() +@IsEnum(ProductType) +@Field(() => ProductType) +type: ProductType; + +// Optional +@IsOptional() +@IsEnum(ProductType) +@Field(() => ProductType, { nullable: true }) +type?: ProductType; +``` + +### Filter + +```typescript +import { createEnumFilter } from "@comet/cms-api"; + +const ProductTypeFilter = createEnumFilter(ProductType); + +// In filter class: +@Field(() => ProductTypeFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => ProductTypeFilter) +type?: typeof ProductTypeFilter; +``` + +### Sort + +Include in sort enum: `type = "type"`. + +--- + +## Enum Array + +### Input + +```typescript +@IsNotEmpty() +@IsEnum(ProductType, { each: true }) +@Field(() => [ProductType]) +types: ProductType[]; + +// Optional +@IsOptional() +@IsEnum(ProductType, { each: true }) +@Field(() => [ProductType], { nullable: true }) +types?: ProductType[]; +``` + +### Filter + +```typescript +import { createEnumsFilter } from "@comet/cms-api"; + +const ProductTypesFilter = createEnumsFilter(ProductType); + +@Field(() => ProductTypesFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => ProductTypesFilter) +types?: typeof ProductTypesFilter; +``` + +### Sort + +Enum arrays are NOT sortable. + +## Rules + +- Import the enum from the entity file or its dedicated enum file. +- `createEnumFilter` for single enum, `createEnumsFilter` for array enum (note the 's'). +- The filter variable must be declared OUTSIDE the filter class (top-level const). +- **Enum value convention**: Use PascalCase for both keys and values (e.g., `Published = "Published"`, `OutOfStock = "OutOfStock"`), NOT UPPER_SNAKE_CASE. diff --git a/package-skills/comet-api-graphql/references/field-03-relation.md b/package-skills/comet-api-graphql/references/field-03-relation.md new file mode 100644 index 0000000000..ac7b57dd78 --- /dev/null +++ b/package-skills/comet-api-graphql/references/field-03-relation.md @@ -0,0 +1,217 @@ +# Field Type: Relations + +> **Note**: Create/update handling for relations belongs in the **service** (not the resolver). The resolver only handles `@ResolveField` methods for field resolution. See [gen-07-service.md](gen-07-service.md) for the service patterns. + +## ManyToOne + +### Input — required + +```typescript +@IsNotEmpty() +@IsUUID() +@Field(() => ID) +category: string; // FK as UUID string +``` + +### Input — nullable + +```typescript +@IsOptional() +@IsUUID() +@Field(() => ID, { nullable: true }) +category?: string; +``` + +### Input — FileUpload (non-UUID) + +```typescript +@IsNotEmpty() +@IsString() // NOT @IsUUID — FileUpload uses string IDs +@Field() +image: string; +``` + +### Filter + +```typescript +@Field(() => ManyToOneFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => ManyToOneFilter) +category?: ManyToOneFilter; +``` + +### Sort + +Include relation name in sort enum: `category = "category"`. +Nested sort: `category_title = "category_title"` (one level deep). + +### Service — create + +```typescript +const { category: categoryInput, ...assignInput } = input; +const entity = this.entityManager.create(Entity, { + ...assignInput, + category: Reference.create(await this.entityManager.findOneOrFail(Category, categoryInput)), +}); +``` + +### Service — update (nullable) + +```typescript +if (categoryInput !== undefined) { + entity.category = categoryInput ? Reference.create(await this.entityManager.findOneOrFail(Category, categoryInput)) : undefined; +} +``` + +### ResolveField (in resolver) + +```typescript +// Required +@ResolveField(() => Category) +async category(@Parent() entity: Entity): Promise { + return entity.category.loadOrFail(); +} + +// Nullable +@ResolveField(() => Category, { nullable: true }) +async category(@Parent() entity: Entity): Promise { + return entity.category?.loadOrFail(); +} +``` + +--- + +## ManyToMany + +### Input + +```typescript +@Field(() => [ID], { defaultValue: [] }) +@IsArray() +@IsUUID(undefined, { each: true }) +products: string[]; +``` + +### Filter + +```typescript +@Field(() => ManyToManyFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => ManyToManyFilter) +products?: ManyToManyFilter; +``` + +### Sort + +ManyToMany is NOT sortable. + +### Service — create/update + +```typescript +if (productsInput) { + const products = await this.entityManager.find(Product, { id: productsInput }); + if (products.length != productsInput.length) throw new Error("Couldn't find all products that were passed as input"); + await entity.products.loadItems(); + entity.products.set(products.map((product) => Reference.create(product))); +} +``` + +### ResolveField (in resolver) + +```typescript +@ResolveField(() => [Product]) +async products(@Parent() entity: Entity): Promise { + return entity.products.loadItems(); +} +``` + +--- + +## OneToMany + +### Child has own CRUD (separate entity) — excluded from parent input + +The OneToMany is **not in the parent's input DTO**. Parent only gets a `@ResolveField`. The child entity has its own full CRUD resolver/service, and its ManyToOne back to the parent uses the **dedicated resolver arg** pattern (see [feature-03-dedicated-arg.md](feature-03-dedicated-arg.md)). + +### Child is nested (no own CRUD) — uses nested input + +The child is only edited through the parent. Uses `orphanRemoval: true` on the OneToMany. See [gen-06-nested-input.md](gen-06-nested-input.md) for the nested input pattern. + +### Filter + +```typescript +@Field(() => OneToManyFilter, { nullable: true }) +@ValidateNested() +@IsOptional() +@Type(() => OneToManyFilter) +items?: OneToManyFilter; +``` + +### Sort + +OneToMany is NOT sortable. + +### ResolveField (in resolver) + +```typescript +@ResolveField(() => [ChildEntity]) +async items(@Parent() entity: Entity): Promise { + return entity.items.loadItems(); +} +``` + +--- + +## OneToOne + +### Filter + +OneToOne is **NOT included** in filter. + +### Input — as nested object + +Similar to nested input pattern. Load existing or create new. + +### ResolveField (in resolver) + +```typescript +// Nullable +@ResolveField(() => RelatedEntity, { nullable: true }) +async related(@Parent() entity: Entity): Promise { + return entity.related?.loadOrFail(); +} + +// Required +@ResolveField(() => RelatedEntity) +async related(@Parent() entity: Entity): Promise { + return entity.related.loadOrFail(); +} +``` + +--- + +## Populate (in Service — findAll) + +Add conditional populate in the service's `findAll` method for each relation with a `@ResolveField` in the resolver. The resolver extracts requested fields from `@Info()` and passes them to the service: + +```typescript +// In service findAll method: +const populate: string[] = []; +if (fields?.includes("category")) { + populate.push("category"); +} +if (fields?.includes("products")) { + populate.push("products"); +} +``` + +```typescript +// In resolver list query: +@Query(() => PaginatedProducts) +async products(@Args() args: ProductsArgs, @Info() info: GraphQLResolveInfo): Promise { + const fields = extractGraphqlFields(info, { root: "nodes" }); + return this.productsService.findAll(args, fields); +} +``` diff --git a/package-skills/comet-api-graphql/references/field-04-block.md b/package-skills/comet-api-graphql/references/field-04-block.md new file mode 100644 index 0000000000..b236307822 --- /dev/null +++ b/package-skills/comet-api-graphql/references/field-04-block.md @@ -0,0 +1,86 @@ +# Field Type: Blocks (CMS Content) + +Block fields use `@RootBlock(BlockType)` decorator and store structured content data. + +> **Note**: Create/update handling for blocks belongs in the **service**. The resolver delegates block field resolution to the service's `transformToPlain` method. See [gen-07-service.md](gen-07-service.md). + +## Input + +```typescript +import { BlockInputInterface, DamImageBlock, PartialType, RootBlockInputScalar, isBlockInputInterface } from "@comet/cms-api"; +import { Transform } from "class-transformer"; +import { IsNotEmpty, ValidateNested } from "class-validator"; + +// In input class: +@IsNotEmpty() +@Field(() => RootBlockInputScalar(DamImageBlock)) +@Transform(({ value }) => (isBlockInputInterface(value) ? value : DamImageBlock.blockInputFactory(value)), { toClassOnly: true }) +@ValidateNested() +image: BlockInputInterface; +``` + +The 4-decorator pattern is always the same: + +1. `@Field(() => RootBlockInputScalar(BlockType))` +2. `@Transform(({ value }) => (isBlockInputInterface(value) ? value : BlockType.blockInputFactory(value)), { toClassOnly: true })` +3. `@ValidateNested()` +4. `image: BlockInputInterface;` + +## Filter / Sort + +Block fields are **NOT included** in filter or sort. + +## Service — Create + +```typescript +const { image: imageInput, ...assignInput } = input; +const entity = this.entityManager.create(Entity, { + ...assignInput, + image: imageInput.transformToBlockData(), +}); +``` + +## Service — Update + +```typescript +const { image: imageInput, ...assignInput } = input; +entity.assign({ ...assignInput }); +if (imageInput) { + entity.image = imageInput.transformToBlockData(); +} +``` + +## Service — transformToPlain helper + +The service injects `BlocksTransformerService` and exposes a method for the resolver's `@ResolveField`: + +```typescript +import { BlocksTransformerService } from "@comet/cms-api"; + +// In service constructor: +private readonly blocksTransformer: BlocksTransformerService, + +// Service method: +async transformToPlain(blockData: object): Promise { + return this.blocksTransformer.transformToPlain(blockData); +} +``` + +## ResolveField (in resolver) + +The resolver delegates block transformation to the service: + +```typescript +import { RootBlockDataScalar, DamImageBlock } from "@comet/cms-api"; + +@ResolveField(() => RootBlockDataScalar(DamImageBlock)) +async image(@Parent() entity: Entity): Promise { + return this.productsService.transformToPlain(entity.image); +} +``` + +## Rules + +- Always destructure block fields from input before spreading (in the service). +- `BlocksTransformerService` is injected in the **service**, not the resolver. +- Common block types: `DamImageBlock`, `DamFileBlock`, custom `*Block` types from the project. diff --git a/package-skills/comet-api-graphql/references/field-05-json-embedded.md b/package-skills/comet-api-graphql/references/field-05-json-embedded.md new file mode 100644 index 0000000000..bb22e6e3a6 --- /dev/null +++ b/package-skills/comet-api-graphql/references/field-05-json-embedded.md @@ -0,0 +1,65 @@ +# Field Type: JSON / Embedded Objects + +## JSON Properties (stored as JSON column) + +### Input — object + +```typescript +import { ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; + +// Required object +@IsNotEmpty() +@ValidateNested() +@Type(() => AddressInput) +@Field(() => AddressInput) +address: AddressInput; + +// Nullable object — use { nullable: true } WITHOUT defaultValue: null +@IsOptional() +@ValidateNested() +@Type(() => AddressInput) +@Field(() => AddressInput, { nullable: true }) +address?: AddressInput; +``` + +### Input — array of objects + +```typescript +// Array uses @ValidateNested() (NOT { each: true }) +@IsArray() +@ValidateNested() +@Type(() => ContactInput) +@Field(() => [ContactInput], { defaultValue: [] }) +contacts: ContactInput[]; +``` + +Note: For arrays, use `@ValidateNested()` without `{ each: true }` — class-transformer handles the array. + +## Embedded Properties (@Embedded) + +Treated the same as JSON objects in the input. The embedded class itself is used as both input and output type if it has `@InputType()` and `@ObjectType()` decorators. + +```typescript +@IsNotEmpty() +@ValidateNested() +@Type(() => ScopeInput) +@Field(() => ScopeInput) +scope: ScopeInput; +``` + +## Filter / Sort + +JSON and embedded fields are generally **NOT included** in filter or sort unless they have simple scalar sub-fields that are explicitly mapped. + +## Resolver + +JSON/embedded fields are plain objects — they can be spread directly in `assign()`: + +```typescript +const entity = this.entityManager.create(Entity, { + ...assignInput, // includes JSON fields directly +}); +``` + +No special destructuring needed unless the field contains nested relations or blocks. diff --git a/package-skills/comet-api-graphql/references/gen-00-resolver.md b/package-skills/comet-api-graphql/references/gen-00-resolver.md new file mode 100644 index 0000000000..b25cadc72c --- /dev/null +++ b/package-skills/comet-api-graphql/references/gen-00-resolver.md @@ -0,0 +1,234 @@ +# Resolver — Thin Pattern + +Resolvers are **thin** — they handle GraphQL concerns (decorators, argument parsing, field resolution) and delegate all business logic to the service. Resolvers MUST NOT inject `EntityManager` directly. + +## Naming Convention + +| Source | Singular | Plural | Instance | File | +| ----------------- | ----------------- | --------------------------------- | ----------------- | ------------------ | +| `Product` | `Product` | `Products` | `product` | `product` | +| `ProductCategory` | `ProductCategory` | `ProductCategories` | `productCategory` | `product-category` | +| `News` | `News` | `NewsList` (when singular=plural) | `news` | `news` | + +When plural equals singular (e.g. `News`), suffix list query and args with `List` (e.g. `newsList`, `NewsListArgs`). + +## @ResolveField — REQUIRED for Every Relation and Block + +**CRITICAL: Every relation (`@ManyToOne`, `@OneToMany`, `@ManyToMany`) and every block (`@RootBlock`) on the entity MUST have a `@ResolveField` method in the resolver.** This is not optional — it is a mandatory part of resolver generation. + +`@ResolveField` ensures GraphQL only loads relations when the client requests them. For single-entity queries this uses lazy loading (`loadOrFail()`, `loadItems()`). For list queries, the resolver extracts requested fields via `extractGraphqlFields` and passes them to the service, which conditionally populates relations to avoid N+1 queries (see [gen-07-service.md](gen-07-service.md)). + +### Relations — lazy load via entity property + +```typescript +import { Parent, ResolveField } from "@nestjs/graphql"; + +// Required ManyToOne +@ResolveField(() => Category) +async category(@Parent() product: Product): Promise { + return product.category.loadOrFail(); +} + +// Nullable ManyToOne +@ResolveField(() => Category, { nullable: true }) +async category(@Parent() product: Product): Promise { + return product.category?.loadOrFail(); +} + +// ManyToMany +@ResolveField(() => [Tag]) +async tags(@Parent() product: Product): Promise { + return product.tags.loadItems(); +} + +// OneToMany +@ResolveField(() => [Comment]) +async comments(@Parent() product: Product): Promise { + return product.comments.loadItems(); +} +``` + +### Blocks — delegate transformation to service + +Block fields require `BlocksTransformerService`, which lives in the service. The resolver delegates: + +```typescript +import { RootBlockDataScalar, DamImageBlock } from "@comet/cms-api"; + +@ResolveField(() => RootBlockDataScalar(DamImageBlock)) +async mainImage(@Parent() product: Product): Promise { + return this.productsService.transformToPlain(product.mainImage); +} +``` + +### List query — conditional populate for performance + +When the entity has relations with `@ResolveField`, add conditional populate in the list query to avoid N+1 queries. The resolver extracts the requested fields from `@Info()` and passes them to the service: + +```typescript +import { extractGraphqlFields } from "@comet/cms-api"; +import { Info } from "@nestjs/graphql"; +import { GraphQLResolveInfo } from "graphql"; + +@Query(() => PaginatedProducts) +async products( + @Args() + args: ProductsArgs, + @Info() info: GraphQLResolveInfo, +): Promise { + const fields = extractGraphqlFields(info, { root: "nodes" }); + return this.productsService.findAll(args, fields); +} +``` + +The service uses `fields` to conditionally populate — see [gen-07-service.md](gen-07-service.md). + +> **When no relations/blocks exist**: omit `@Info()`, `extractGraphqlFields`, `GraphQLResolveInfo`, and pass `args` directly without `fields`. + +## File: `{entity-name}.resolver.ts` + +### Full CRUD resolver (default) + +Full example for an entity with a ManyToMany relation (`tags`) and a block field (`mainImage`): + +```typescript +import { AffectedEntity, extractGraphqlFields, RequiredPermission, RootBlockDataScalar, DamImageBlock } from "@comet/cms-api"; +import { Args, ID, Info, Mutation, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { GraphQLResolveInfo } from "graphql"; + +import { PaginatedProducts } from "./dto/paginated-products"; +import { ProductInput, ProductUpdateInput } from "./dto/product.input"; +import { ProductsArgs } from "./dto/products.args"; +import { Product } from "./entities/product.entity"; +import { Tag } from "@src/tags/entities/tag.entity"; +import { ProductsService } from "./products.service"; + +@Resolver(() => Product) +@RequiredPermission(["products"], { skipScopeCheck: true }) +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, + @Info() info: GraphQLResolveInfo, + ): Promise { + const fields = extractGraphqlFields(info, { root: "nodes" }); + return this.productsService.findAll(args, fields); + } + + @Mutation(() => Product) + async createProduct( + @Args("input", { type: () => ProductInput }) + input: ProductInput, + ): Promise { + return this.productsService.create(input); + } + + @Mutation(() => Product) + @AffectedEntity(Product) + async updateProduct( + @Args("id", { type: () => ID }) + id: string, + @Args("input", { type: () => ProductUpdateInput }) + input: ProductUpdateInput, + ): Promise { + return this.productsService.update(id, input); + } + + @Mutation(() => Boolean) + @AffectedEntity(Product) + async deleteProduct( + @Args("id", { type: () => ID }) + id: string, + ): Promise { + return this.productsService.delete(id); + } + + // --- @ResolveField: REQUIRED for every relation and block --- + + @ResolveField(() => [Tag]) + async tags(@Parent() product: Product): Promise { + return product.tags.loadItems(); + } + + @ResolveField(() => RootBlockDataScalar(DamImageBlock)) + async mainImage(@Parent() product: Product): Promise { + return this.productsService.transformToPlain(product.mainImage); + } +} +``` + +### Read-only resolver + +When the API is read-only, the resolver only contains queries and `@ResolveField` methods. No `@Mutation` methods, no input imports. + +```typescript +import { AffectedEntity, extractGraphqlFields, RequiredPermission, RootBlockDataScalar, DamImageBlock } from "@comet/cms-api"; +import { Args, ID, Info, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; +import { GraphQLResolveInfo } from "graphql"; + +import { PaginatedProducts } from "./dto/paginated-products"; +import { ProductsArgs } from "./dto/products.args"; +import { Product } from "./entities/product.entity"; +import { Tag } from "@src/tags/entities/tag.entity"; +import { ProductsService } from "./products.service"; + +@Resolver(() => Product) +@RequiredPermission(["products"], { skipScopeCheck: true }) +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, + @Info() info: GraphQLResolveInfo, + ): Promise { + const fields = extractGraphqlFields(info, { root: "nodes" }); + return this.productsService.findAll(args, fields); + } + + // --- @ResolveField: REQUIRED for every relation and block --- + + @ResolveField(() => [Tag]) + async tags(@Parent() product: Product): Promise { + return product.tags.loadItems(); + } + + @ResolveField(() => RootBlockDataScalar(DamImageBlock)) + async mainImage(@Parent() product: Product): Promise { + return this.productsService.transformToPlain(product.mainImage); + } +} +``` + +## Rules + +- **Resolvers MUST be thin** — no `EntityManager`, no business logic, no data transformation. +- **Constructor**: Only inject the service. Never inject `EntityManager` or `BlocksTransformerService` in the resolver. +- **All CRUD operations**: Delegate to service methods (`findOneById`, `findAll`, `create`, `update`, `delete`). +- **EVERY relation and block MUST have a `@ResolveField`** — no exceptions. Never eagerly load relations via `.init()` or hardcoded `populate` in queries. +- **@ResolveField for relations**: Use `entity.relation.loadOrFail()` (ManyToOne) or `entity.relation.loadItems()` (ManyToMany/OneToMany). +- **@ResolveField for blocks**: Delegate transformation to `service.transformToPlain()`. +- **@AffectedEntity**: Add on single query, update, and delete mutations. NOT on list query (unless scoped — see feature-05-scope.md). +- **@RequiredPermission**: Use `skipScopeCheck: true` when entity has no scope (scope mode a). diff --git a/package-skills/comet-api-graphql/references/gen-01-input.md b/package-skills/comet-api-graphql/references/gen-01-input.md new file mode 100644 index 0000000000..71b14dfa72 --- /dev/null +++ b/package-skills/comet-api-graphql/references/gen-01-input.md @@ -0,0 +1,160 @@ +# Input DTO + +## File: `dto/{entity-name}.input.ts` + +```typescript +import { Field, InputType, ID } from "@nestjs/graphql"; +import { Type } from "class-transformer"; +import { PartialType } from "@comet/cms-api"; +import { IsArray, IsNotEmpty, IsString, IsUUID } from "class-validator"; + +@InputType() +export class ProductTagInput { + @IsNotEmpty() + @IsString() + @Field() + title: string; + + @Field(() => [ID], { defaultValue: [] }) + @IsArray() + @IsUUID(undefined, { each: true }) + products: string[]; +} + +@InputType() +export class ProductTagUpdateInput extends PartialType(ProductTagInput) {} +``` + +## Rules + +- **Import `PartialType` from `@comet/cms-api`**, never from `@nestjs/graphql`. +- **UpdateInput** always `extends PartialType(EntityInput)` — no additional fields. +- **`[OptionalProps]`** on the entity does NOT affect validation — still use `@IsNotEmpty()` for required fields. +- **Exclude** from input: `id`, `createdAt`, `updatedAt`, `position` (handled separately), and fields that should not be user-editable. +- **Nullable fields**: Use `@IsOptional()` + `{ nullable: true }` on `@Field`. +- See field-specific reference files for per-type examples. + +## Validation Reference + +Every input field MUST have validation decorators. These are the single source of truth for data integrity — the GraphQL type system alone is not sufficient. + +### Decorator Order Convention + +Always apply decorators in this order (top to bottom): + +1. **Presence**: `@IsNotEmpty()` or `@IsOptional()` +2. **Type/format validator**: `@IsString()`, `@IsInt()`, `@IsEmail()`, `@IsEnum(MyEnum)`, etc. +3. **Constraint validators**: `@MaxLength(120)`, `@Min(1)`, `@Max(100)`, etc. +4. **Nested/transform** (when applicable): `@ValidateNested()`, `@Type(() => ...)`, `@Transform(...)` +5. **GraphQL field**: `@Field()` or `@Field(() => Type, { ... })` + +### Required vs Optional + +| Entity property | Input decorators | +| ---------------------------------- | --------------------------------------------------------------------- | +| Required (no `?`, no default) | `@IsNotEmpty()` + type validator | +| Nullable (`?` or `nullable: true`) | `@IsOptional()` + type validator + `@Field({ nullable: true })` | +| Has default value in entity | `@IsNotEmpty()` in input — the DTO doesn't know about entity defaults | + +> **Important**: `@IsNotEmpty()` rejects `null`, `undefined`, and empty strings. `@IsOptional()` allows `null` and `undefined` (skips all subsequent validators when value is missing). + +### Validator Mapping — Entity Property to Input Decorator + +Derive input validators from the entity's MikroORM property decorators: + +| Entity decorator / type | Input validators | Notes | +| -------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------- | +| `@Property({ type: "text" })` / `string` | `@IsString()` | No length limit | +| `@Property({ length: N })` / `string` | `@IsString()` + `@MaxLength(N)` | Always mirror the length constraint | +| `@Property({ type: "integer" })` / `number` | `@IsInt()` + `@Field(() => Int)` | | +| `@Property({ type: "float" })` / `number` | `@IsNumber()` + `@Field(() => Float)` | | +| `@Property({ type: "boolean" })` / `boolean` | `@IsBoolean()` | | +| `@Property({ type: "date" })` / `Date` | `@IsDate()` | DateTime — use `DateTimeFilter` in filter | +| `@Enum(() => MyEnum)` | `@IsEnum(MyEnum)` + `@Field(() => MyEnum)` | | +| `@Enum(() => MyEnum)` (array) | `@IsEnum(MyEnum, { each: true })` + `@Field(() => [MyEnum])` | | +| `@ManyToOne` (required) | `@IsUUID()` + `@Field(() => ID)` | FK as UUID string | +| `@ManyToOne` (nullable) | `@IsUUID()` + `@Field(() => ID, { nullable: true })` | | +| `@ManyToOne` (FileUpload) | `@IsString()` + `@Field()` | FileUpload uses string IDs, NOT UUID | +| `@ManyToMany` | `@IsArray()` + `@IsUUID(undefined, { each: true })` + `@Field(() => [ID])` | | +| `@RootBlock(BlockType)` | 4-decorator pattern — see [field-04-block.md](field-04-block.md) | | +| `@Embedded(() => Type)` | `@ValidateNested()` + `@Type(() => InputType)` | | +| JSON object | `@ValidateNested()` + `@Type(() => InputType)` | | +| JSON array of objects | `@IsArray()` + `@ValidateNested()` + `@Type(() => InputType)` | | + +### Semantic Validators + +When a field has a well-known format, prefer a semantic validator over the generic type validator: + +| Field meaning | Validator | Instead of | Import | +| ------------------- | ---------------- | ------------- | ----------------- | +| Email | `@IsEmail()` | `@IsString()` | `class-validator` | +| URL | `@IsUrl()` | `@IsString()` | `class-validator` | +| Latitude | `@IsLatitude()` | `@IsNumber()` | `class-validator` | +| Longitude | `@IsLongitude()` | `@IsNumber()` | `class-validator` | +| Slug | `@IsSlug()` | `@IsString()` | `@comet/cms-api` | +| UUID (non-relation) | `@IsUUID()` | `@IsString()` | `class-validator` | + +### Constraint Validators + +Apply constraint validators when the entity or domain logic imposes limits: + +| Constraint | Decorator | Example | +| ----------------- | --------------------- | ----------------------------------------------------- | +| String max length | `@MaxLength(N)` | Entity has `@Property({ length: 120 })` | +| String min length | `@MinLength(N)` | E.g. passwords, codes | +| Number minimum | `@Min(N)` | E.g. `@Min(1)` for position, `@Min(0)` for quantities | +| Number maximum | `@Max(N)` | E.g. `@Max(100)` for percentage | +| Array min size | `@ArrayMinSize(N)` | E.g. at least 1 tag required | +| Array max size | `@ArrayMaxSize(N)` | E.g. max 10 images | +| Regex pattern | `@Matches(/pattern/)` | E.g. hex color codes, postal codes | + +### When to use `@Type(() => ...)` from class-transformer + +`@Type()` is required whenever a field contains a **nested object** that needs class instantiation for validation to work: + +| Field type | Needs `@Type()`? | Example | +| ---------------------------------------------- | -------------------------------- | ------------------------------------------ | +| Scalar (`string`, `number`, `boolean`, `Date`) | No | — | +| Enum | No | — | +| UUID / ID (string) | No | — | +| Nested `@InputType()` object | **Yes** | `@Type(() => AddressInput)` | +| Array of nested objects | **Yes** | `@Type(() => ContactInput)` | +| Filter DTO | **Yes** | `@Type(() => ProductFilter)` | +| Sort DTO | **Yes** | `@Type(() => ProductSort)` | +| Block input (`BlockInputInterface`) | No — uses `@Transform()` instead | See [field-04-block.md](field-04-block.md) | + +### Array Field Validation + +For array fields, validators that check individual items use `{ each: true }`: + +```typescript +// Array of UUIDs +@IsArray() +@IsUUID(undefined, { each: true }) +@Field(() => [ID], { defaultValue: [] }) +items: string[]; + +// Array of enums +@IsArray() +@IsEnum(ProductType, { each: true }) +@Field(() => [ProductType]) +types: ProductType[]; + +// Array of nested objects +@IsArray() +@ValidateNested() +@Type(() => ContactInput) +@Field(() => [ContactInput], { defaultValue: [] }) +contacts: ContactInput[]; +``` + +> **Note**: `@ValidateNested()` for arrays does NOT need `{ each: true }` — class-transformer handles the array iteration. + +### Common Mistakes to Avoid + +- **Missing `@IsNotEmpty()`**: Every required field MUST have `@IsNotEmpty()`. Without it, empty strings and null values pass validation silently. +- **Missing `@MaxLength()`**: If the entity has `@Property({ length: N })`, the input MUST have `@MaxLength(N)`. Without it, values exceeding the column length cause database errors at runtime. +- **Wrong `PartialType` import**: Always from `@comet/cms-api`, never from `@nestjs/graphql`. The NestJS version doesn't preserve validators correctly. +- **`@IsString()` for UUID relations**: ManyToOne relation fields use `@IsUUID()`, not `@IsString()`. Exception: FileUpload IDs use `@IsString()`. +- **Missing `@Type()` for nested objects**: Without `@Type()`, class-transformer cannot instantiate the nested class, and `@ValidateNested()` silently skips validation. +- **`@IsOptional()` with `@IsNotEmpty()`**: Never combine these — they contradict each other. Use one or the other. diff --git a/package-skills/comet-api-graphql/references/gen-02-filter.md b/package-skills/comet-api-graphql/references/gen-02-filter.md new file mode 100644 index 0000000000..1dcd070cc8 --- /dev/null +++ b/package-skills/comet-api-graphql/references/gen-02-filter.md @@ -0,0 +1,87 @@ +# Filter DTO + +## File: `dto/{entity-name}.filter.ts` + +```typescript +import { IsOptional, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { Field, InputType } from "@nestjs/graphql"; +import { DateTimeFilter, IdFilter, ManyToManyFilter, OneToManyFilter, StringFilter } from "@comet/cms-api"; + +@InputType() +export class ProductTagFilter { + @Field(() => IdFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => IdFilter) + id?: IdFilter; + + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => StringFilter) + title?: StringFilter; + + @Field(() => ManyToManyFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => ManyToManyFilter) + products?: ManyToManyFilter; + + @Field(() => OneToManyFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => OneToManyFilter) + productsWithStatus?: OneToManyFilter; + + @Field(() => DateTimeFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => DateTimeFilter) + createdAt?: DateTimeFilter; + + @Field(() => DateTimeFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => DateTimeFilter) + updatedAt?: DateTimeFilter; + + // --- Always include and/or --- + @Field(() => [ProductTagFilter], { nullable: true }) + @Type(() => ProductTagFilter) + @ValidateNested({ each: true }) + @IsOptional() + and?: ProductTagFilter[]; + + @Field(() => [ProductTagFilter], { nullable: true }) + @Type(() => ProductTagFilter) + @ValidateNested({ each: true }) + @IsOptional() + or?: ProductTagFilter[]; +} +``` + +## Filter Type Mapping + +| Property Type | Filter Class | Import from | +| ------------------------------ | --------------------------- | ---------------- | +| `string` / `text` | `StringFilter` | `@comet/cms-api` | +| `number` / `int` / `float` | `NumberFilter` | `@comet/cms-api` | +| `boolean` | `BooleanFilter` | `@comet/cms-api` | +| `Date` (datetime) | `DateTimeFilter` | `@comet/cms-api` | +| `Date` (date only / LocalDate) | `DateFilter` | `@comet/cms-api` | +| UUID / ID | `IdFilter` | `@comet/cms-api` | +| Enum (single) | `createEnumFilter(MyEnum)` | `@comet/cms-api` | +| Enum (array) | `createEnumsFilter(MyEnum)` | `@comet/cms-api` | +| `@ManyToOne` | `ManyToOneFilter` | `@comet/cms-api` | +| `@OneToMany` | `OneToManyFilter` | `@comet/cms-api` | +| `@ManyToMany` | `ManyToManyFilter` | `@comet/cms-api` | +| `@OneToOne` | NOT included in filter | — | + +## Rules + +- **Always include** `id` filter (IdFilter), `and`/`or` recursive filters, `createdAt`/`updatedAt` if entity has them. +- **Exclude** fields that should not be filterable (e.g. block fields, JSON fields). +- **OneToOne** relations are never in filter. +- Every filter field is `nullable: true` and `@IsOptional()`. +- Enum filters: Use `createEnumFilter(MyEnum)` which returns a class — use it directly as the type. diff --git a/package-skills/comet-api-graphql/references/gen-03-sort.md b/package-skills/comet-api-graphql/references/gen-03-sort.md new file mode 100644 index 0000000000..6d07efb3d4 --- /dev/null +++ b/package-skills/comet-api-graphql/references/gen-03-sort.md @@ -0,0 +1,65 @@ +# Sort DTO + +## File: `dto/{entity-name}.sort.ts` + +## Sort Field Rules + +**CRITICAL: The sort enum MUST include EVERY sortable field from the entity. Do not skip any.** Walk through every property in the entity and add it to the enum unless it falls into the exclude list below. + +- **Include**: ALL scalar `@Property` fields (string, number, boolean, date, float, int), `position`, `id`, `createdAt`, `updatedAt`. +- **Include enums**: All `@Enum()` fields. +- **Include nested**: One level of ManyToOne relation fields as `{relation}_{field}` (e.g. `type_title`). +- **Exclude ONLY**: Relations (OneToMany, ManyToMany), block fields (`@RootBlock`), JSON/embedded fields. +- **Position**: Include `position` in enum if entity has position field. + +## Example + +For an entity `ProductTag` with fields: `title` (string), `slug` (string), `priority` (int), `isVisible` (boolean): + +```typescript +import { SortDirection } from "@comet/cms-api"; +import { Field, InputType, registerEnumType } from "@nestjs/graphql"; +import { IsEnum } from "class-validator"; + +export enum ProductTagSortField { + title = "title", + slug = "slug", + priority = "priority", + isVisible = "isVisible", + createdAt = "createdAt", + updatedAt = "updatedAt", + id = "id", +} + +registerEnumType(ProductTagSortField, { + name: "ProductTagSortField", +}); + +@InputType() +export class ProductTagSort { + @Field(() => ProductTagSortField) + @IsEnum(ProductTagSortField) + field: ProductTagSortField; + + @Field(() => SortDirection, { defaultValue: SortDirection.ASC }) + @IsEnum(SortDirection) + direction: SortDirection = SortDirection.ASC; +} +``` + +## Nested Sort Example + +When entity has `@ManyToOne(() => ProductCategoryType) type`, add: + +```typescript +export enum ProductCategorySortField { + title = "title", + slug = "slug", + position = "position", + type = "type", + createdAt = "createdAt", + updatedAt = "updatedAt", + id = "id", + type_title = "type_title", // nested relation sort +} +``` diff --git a/package-skills/comet-api-graphql/references/gen-04-args.md b/package-skills/comet-api-graphql/references/gen-04-args.md new file mode 100644 index 0000000000..2d45e2923e --- /dev/null +++ b/package-skills/comet-api-graphql/references/gen-04-args.md @@ -0,0 +1,39 @@ +# Args DTO + +## File: `dto/{entity-names}.args.ts` + +```typescript +import { ArgsType, Field } from "@nestjs/graphql"; +import { Type } from "class-transformer"; +import { IsOptional, IsString, ValidateNested } from "class-validator"; +import { OffsetBasedPaginationArgs, SortDirection } from "@comet/cms-api"; +import { ProductTagFilter } from "./product-tag.filter"; +import { ProductTagSort, ProductTagSortField } from "./product-tag.sort"; + +@ArgsType() +export class ProductTagsArgs extends OffsetBasedPaginationArgs { + @Field({ nullable: true }) + @IsOptional() + @IsString() + search?: string; + + @Field(() => ProductTagFilter, { nullable: true }) + @ValidateNested() + @Type(() => ProductTagFilter) + @IsOptional() + filter?: ProductTagFilter; + + @Field(() => [ProductTagSort], { defaultValue: [{ field: ProductTagSortField.createdAt, direction: SortDirection.ASC }] }) + @ValidateNested({ each: true }) + @Type(() => ProductTagSort) + sort: ProductTagSort[]; +} +``` + +## Rules + +- **Extends** `OffsetBasedPaginationArgs` when `paging: true` (default). When `paging: false`, use plain `@ArgsType()` without extending. +- **Default sort**: `createdAt ASC` normally. `position ASC` if entity has position field. +- **Search field**: Include when entity has string fields (searchable by default). +- **File name**: Uses plural entity name (e.g. `product-tags.args.ts`). +- When singular equals plural (e.g. `News`), name the class `NewsListArgs`. diff --git a/package-skills/comet-api-graphql/references/gen-05-paginated.md b/package-skills/comet-api-graphql/references/gen-05-paginated.md new file mode 100644 index 0000000000..10a74a5f77 --- /dev/null +++ b/package-skills/comet-api-graphql/references/gen-05-paginated.md @@ -0,0 +1,16 @@ +# Paginated Response + +## File: `dto/paginated-{entity-names}.ts` + +```typescript +import { ObjectType } from "@nestjs/graphql"; +import { PaginatedResponseFactory } from "@comet/cms-api"; +import { Product } from "../entities/product.entity"; + +@ObjectType() +export class PaginatedProducts extends PaginatedResponseFactory.create(Product) {} +``` + +## Rules + +- Class name: `Paginated{EntityPlural}` (e.g. `PaginatedProductTags`, `PaginatedNews`). diff --git a/package-skills/comet-api-graphql/references/gen-06-nested-input.md b/package-skills/comet-api-graphql/references/gen-06-nested-input.md new file mode 100644 index 0000000000..76941e9407 --- /dev/null +++ b/package-skills/comet-api-graphql/references/gen-06-nested-input.md @@ -0,0 +1,63 @@ +# Nested Input DTO + +Generated for each `@OneToMany` relation where the child entity is nested (no own CRUD, managed inline through the parent). The parent's `@OneToMany` typically uses `orphanRemoval: true`. + +## File: `dto/{entity-name}-nested-{related-entity-name}.input.ts` + +Example: `ProductTag` has `@OneToMany(() => ProductToTag, ..., { orphanRemoval: true })`: + +```typescript +import { Field, InputType, ID } from "@nestjs/graphql"; +import { IsNotEmpty, IsString, IsUUID } from "class-validator"; + +@InputType() +export class ProductTagNestedProductToTagInput { + @IsNotEmpty() + @IsString() + @Field() + status: string; + + @IsNotEmpty() + @IsUUID() + @Field(() => ID) + product: string; +} +``` + +## Rules + +- **Name pattern**: `{ParentEntity}Nested{ChildEntity}Input` (e.g. `ProductTagNestedProductToTagInput`). +- **Include** all fields of the child entity except `id`, `createdAt`, `updatedAt`, and the back-reference to the parent. +- **ManyToOne** in the child becomes a UUID string field (the FK ID). +- **No UpdateInput** — nested inputs are always fully replaced (orphanRemoval). + +## Usage in Parent Input + +```typescript +@Field(() => [ProductTagNestedProductToTagInput], { defaultValue: [] }) +@IsArray() +@Type(() => ProductTagNestedProductToTagInput) +productsWithStatus: ProductTagNestedProductToTagInput[]; +``` + +## Usage in Service (create/update) + +Nested input handling belongs in the **service** (not the resolver): + +```typescript +if (productsWithStatusInput) { + await entity.productsWithStatus.loadItems(); + entity.productsWithStatus.set( + await Promise.all( + productsWithStatusInput.map(async (itemInput) => { + const { product: productInput, ...assignInput } = itemInput; + const item = this.entityManager.assign(new ProductToTag(), { + ...assignInput, + product: Reference.create(await this.entityManager.findOneOrFail(Product, productInput)), + }); + return item; + }), + ), + ); +} +``` diff --git a/package-skills/comet-api-graphql/references/gen-07-service.md b/package-skills/comet-api-graphql/references/gen-07-service.md new file mode 100644 index 0000000000..37c2292690 --- /dev/null +++ b/package-skills/comet-api-graphql/references/gen-07-service.md @@ -0,0 +1,205 @@ +# Service — Base Pattern + +The service is the **single source of business logic**. All entity access (find, create, update, delete) goes through the service. Resolvers MUST NOT use `EntityManager` directly. + +## Naming Convention + +| Source | Service Class | Instance | File | +| ----------------- | -------------------------- | -------------------------- | ------------------------------- | +| `Product` | `ProductsService` | `productsService` | `products.service.ts` | +| `ProductCategory` | `ProductCategoriesService` | `productCategoriesService` | `product-categories.service.ts` | +| `News` | `NewsService` | `newsService` | `news.service.ts` | + +The CRUD service always uses **plural** naming. + +## File: `{entity-names}.service.ts` + +This is the **minimal base** for an entity with only scalar fields (no relations, no blocks, no position). For relation/block handling, see [field-03-relation.md](field-03-relation.md) and [field-04-block.md](field-04-block.md). + +### Full CRUD service (default) + +```typescript +import { gqlArgsToMikroOrmQuery, gqlSortToMikroOrmOrderBy } from "@comet/cms-api"; +import { EntityManager, FindOptions } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; + +import { PaginatedProducts } from "./dto/paginated-products"; +import { ProductInput, ProductUpdateInput } from "./dto/product.input"; +import { ProductsArgs } from "./dto/products.args"; +import { Product } from "./entities/product.entity"; + +@Injectable() +export class ProductsService { + constructor(private readonly entityManager: EntityManager) {} + + async findOneById(id: string): Promise { + return this.entityManager.findOneOrFail(Product, id); + } + + async findAll({ search, filter, sort, offset, limit }: ProductsArgs): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(Product)); + 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 create(input: ProductInput): Promise { + const product = this.entityManager.create(Product, { ...input }); + await this.entityManager.flush(); + return product; + } + + async update(id: string, input: ProductUpdateInput): Promise { + const product = await this.entityManager.findOneOrFail(Product, id); + product.assign({ ...input }); + await this.entityManager.flush(); + return product; + } + + async delete(id: string): Promise { + const product = await this.entityManager.findOneOrFail(Product, id); + this.entityManager.remove(product); + await this.entityManager.flush(); + return true; + } +} +``` + +### Read-only service + +When the API is read-only, the service only contains `findOneById` and `findAll`. No input imports, no `create`, `update`, or `delete` methods. + +```typescript +import { gqlArgsToMikroOrmQuery, gqlSortToMikroOrmOrderBy } from "@comet/cms-api"; +import { EntityManager, FindOptions } from "@mikro-orm/postgresql"; +import { Injectable } from "@nestjs/common"; + +import { PaginatedProducts } from "./dto/paginated-products"; +import { ProductsArgs } from "./dto/products.args"; +import { Product } from "./entities/product.entity"; + +@Injectable() +export class ProductsService { + constructor(private readonly entityManager: EntityManager) {} + + async findOneById(id: string): Promise { + return this.entityManager.findOneOrFail(Product, id); + } + + async findAll({ search, filter, sort, offset, limit }: ProductsArgs): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(Product)); + 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); + } +} +``` + +## Adding relations or blocks + +When the entity has relations (`@ManyToOne`, `@OneToMany`, `@ManyToMany`) or block fields (`@RootBlock`), the service needs additional code. See the field-type references for details. Summary of changes: + +1. **Constructor** — add `BlocksTransformerService` if entity has blocks. +2. **Destructure** relation/block fields from input before spreading: `const { category: categoryInput, image: imageInput, ...assignInput } = input;` +3. **Populate** — in `findAll`, accept optional `fields?: string[]` param and build conditional populate for relations that have a `@ResolveField` in the resolver. +4. **transformToPlain** — if entity has blocks, expose a method for the resolver's `@ResolveField` to call. + +### findAll with populate + +When the entity has relations with `@ResolveField` in the resolver, the service accepts a `fields` parameter to conditionally populate: + +> **IMPORTANT:** Never pass the `fields` array directly to MikroORM's `populate` (e.g. `options.populate = fields`). The `fields` array from `extractGraphqlFields` contains **all** requested GraphQL fields, including computed `@ResolveField`s (like `variantCount`) that are not actual entity properties or relations. Passing these to `populate` causes MikroORM to throw `"Entity does not have property X"`. Always use explicit field-by-field checks as shown below. + +```typescript +async findAll({ search, filter, sort, offset, limit }: ProductsArgs, fields?: string[]): Promise { + const where = gqlArgsToMikroOrmQuery({ search, filter }, this.entityManager.getMetadata(Product)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options: FindOptions = { offset, limit }; + if (sort) { + options.orderBy = gqlSortToMikroOrmOrderBy(sort); + } + const populate: string[] = []; + if (fields?.includes("category")) { + populate.push("category"); + } + if (fields?.includes("tags")) { + populate.push("tags"); + } + if (populate.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (options as any).populate = populate; + } + const [entities, totalCount] = await this.entityManager.findAndCount(Product, where, options); + return new PaginatedProducts(entities, totalCount); +} +``` + +### create with relations and blocks + +```typescript +async create(input: ProductInput): Promise { + const { category: categoryInput, image: imageInput, ...assignInput } = input; + const product = this.entityManager.create(Product, { + ...assignInput, + category: Reference.create(await this.entityManager.findOneOrFail(Category, categoryInput)), + image: imageInput.transformToBlockData(), + }); + await this.entityManager.flush(); + return product; +} +``` + +### update with relations and blocks + +```typescript +async update(id: string, input: ProductUpdateInput): Promise { + const product = await this.entityManager.findOneOrFail(Product, id); + const { category: categoryInput, image: imageInput, ...assignInput } = input; + product.assign({ ...assignInput }); + if (categoryInput !== undefined) { + product.category = categoryInput + ? Reference.create(await this.entityManager.findOneOrFail(Category, categoryInput)) + : undefined; + } + if (imageInput) { + product.image = imageInput.transformToBlockData(); + } + await this.entityManager.flush(); + return product; +} +``` + +### Block transform helper + +When the entity has block fields, add `BlocksTransformerService` to the constructor and expose a transform method for the resolver's `@ResolveField`: + +```typescript +import { BlocksTransformerService } from "@comet/cms-api"; + +constructor( + private readonly entityManager: EntityManager, + private readonly blocksTransformer: BlocksTransformerService, +) {} + +async transformToPlain(blockData: object): Promise { + return this.blocksTransformer.transformToPlain(blockData); +} +``` + +## Rules + +- **Always `@Injectable()`**. +- **Naming**: Always plural (`ProductsService`, `WeatherStationsService`). +- **Constructor**: Always inject `EntityManager`. Add `BlocksTransformerService` if entity has blocks. +- **The service is the single source of business logic** — resolvers MUST NOT use `EntityManager` directly. +- **All entity access** (find, create, update, delete) goes through the service. +- **No `@Args()`, `@Query()`, `@Mutation()`** in the service — those are resolver concerns. +- **Destructure** relation and block fields from input before spreading, same as described in [field-03-relation.md](field-03-relation.md) and [field-04-block.md](field-04-block.md). +- **Nullable ManyToOne in update**: Check `if (relationInput !== undefined)` to distinguish "not provided" from "set to null". +- When entity has no relations with `@ResolveField`, omit the `fields` parameter from `findAll`. From 283fb88b6d45cf17b28ff84abef5706a97db0108 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 13:37:49 +0100 Subject: [PATCH 02/12] add comet-admin-translatable-enum --- .../comet-admin-translatable-enum/SKILL.md | 102 +++++++++ .../references/enum-00-translatable.md | 95 ++++++++ .../references/enum-01-chip.md | 184 ++++++++++++++++ .../references/enum-02-editable-chip.md | 204 ++++++++++++++++++ .../references/enum-03-select-field.md | 106 +++++++++ .../references/enum-04-autocomplete-field.md | 164 ++++++++++++++ .../references/enum-05-radio-group-field.md | 108 ++++++++++ .../references/enum-06-checkbox-list-field.md | 108 ++++++++++ .../references/enum-helper-chip-icon.md | 27 +++ .../enum-helper-create-translatable-enum.md | 40 ++++ .../references/enum-helper-enum-chip.md | 58 +++++ .../enum-helper-record-to-options.md | 21 ++ 12 files changed, 1217 insertions(+) create mode 100644 package-skills/comet-admin-translatable-enum/SKILL.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-00-translatable.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-01-chip.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-02-editable-chip.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-03-select-field.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-04-autocomplete-field.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-05-radio-group-field.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-06-checkbox-list-field.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-helper-chip-icon.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-helper-create-translatable-enum.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-helper-enum-chip.md create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-helper-record-to-options.md diff --git a/package-skills/comet-admin-translatable-enum/SKILL.md b/package-skills/comet-admin-translatable-enum/SKILL.md new file mode 100644 index 0000000000..dbe6e584e9 --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/SKILL.md @@ -0,0 +1,102 @@ +--- +name: comet-admin-translatable-enum +description: | + Creates React translatable enum components, colored chip components, editable chip components + (with Apollo query + mutation), Final Form field components (Select, Autocomplete, RadioGroup, + CheckboxList), for GraphQL enums. Use when a user asks to "create a translation component for X enum", "create a chip for X enum", "create a + status chip for X enum", "create an editable chip for X enum on Y entity", "create a + select/autocomplete/radio/checkbox field for X enum". Also use when a GQL enum type from @src/graphql.generated + is referenced in admin code. Handles both explicit requests and auto-detection during + feature work. +--- + +# Translatable Enum Skill + +Generate translatable enum components, chips, editable chips, and form field components by reading the GraphQL schema and producing files following the patterns in `references/`. + +## Prerequisites + +1. **Find enum values** — look up the enum in `api/schema.gql` or the generated types file. +2. **Check if file already exists** — before creating, search for an existing component. If it exists, reuse it. +3. **Confirm output path** with the user if the domain is unclear. + +## Core Imports + +| Import | Source | Purpose | +| ------------------------------ | ----------------------------------------------------- | ---------------------------------------------------------------- | +| `createTranslatableEnum` | `@src/common/components/enums/createTranslatableEnum` | Factory for translatable enum component | +| `defineMessage` | `react-intl` | Define i18n message descriptors | +| `EnumChip` | `@src/common/components/enums/enumChip/EnumChip` | Generic chip wrapper with dropdown menu | +| `ChipIcon` | project-specific path | Loading/dropdown icon for chips | +| `recordToOptions` | `@src/common/components/enums/recordToOptions` | Convert record to options array for Select/Radio/Checkbox fields | +| `useAutocompleteOptions` | `@src/common/components/enums/useAutocompleteOptions` | Hook for autocomplete field options | +| `SelectField` | `@comet/admin` | Select dropdown field | +| `AutocompleteField` | `@comet/admin` | Autocomplete with search/filter | +| `RadioGroupField` | `@comet/admin` | Radio group field | +| `CheckboxListField` | `@comet/admin` | Checkbox list multi-select field | +| `LocalErrorScopeApolloContext` | `@comet/admin` | Scoped error handling for editable chips | + +## Generation Workflow + +### Step 1 — Verify helpers exist (BEFORE generating components) + +Before generating any component, verify these helpers exist. Create from helper references if missing: + +| Helper | Expected path | Reference | +| ------------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `createTranslatableEnum` | Search in project, typically in common/enums area | [enum-helper-create-translatable-enum.md](references/enum-helper-create-translatable-enum.md) | +| `EnumChip` | Search in project, typically in common/enums area | [enum-helper-enum-chip.md](references/enum-helper-enum-chip.md) | +| `ChipIcon` | Search in project (project-specific) | [enum-helper-chip-icon.md](references/enum-helper-chip-icon.md) | +| `recordToOptions` | Search in project, typically in common/enums area | [enum-helper-record-to-options.md](references/enum-helper-record-to-options.md) | + +### Step 2 — Generate the translatable enum (base component) + +**Always read [enum-00-translatable.md](references/enum-00-translatable.md) first** — it is the base pattern. Every other component depends on this. + +### Step 3 — Generate the requested component + +Read the applicable reference file for the component type the user requested. Default to **SelectField** for form fields if unspecified. + +### Step 4 — Generate Storybook stories (conditional) + +Only generate story files when Storybook is set up in the package (check for a `.storybook/` folder or config in the project). Story patterns are included in each reference file. + +## Key Rules + +- The translation `id` should have semantic meaning derived from the file path (e.g. `location/components/locationStatus/LocationStatus` → `location.locationStatus.active`). +- Files with `.generated` suffix are auto-generated by GraphQL codegen — do not create them manually. +- Prefer `` over `intl.formatMessage()` wherever possible. Only use `intl.formatMessage()` when a prop requires a plain `string` type. +- Always check if the component already exists before creating — reuse existing components. +- For editable chips, auto-detect the query and mutation from `api/schema.gql`. Ask the user if ambiguous. + +## Component Type Reference + +Read the relevant reference file based on what the user requests: + +| Component | When to use | Reference | +| ---------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- | +| **Translatable Enum** (base) | Always created first — provides labels, component, message maps | [enum-00-translatable.md](references/enum-00-translatable.md) | +| **Chip** | Colored chip display with optional dropdown | [enum-01-chip.md](references/enum-01-chip.md) | +| **Editable Chip** | Chip + Apollo query/mutation for inline editing | [enum-02-editable-chip.md](references/enum-02-editable-chip.md) | +| **SelectField** | Default single-value form field selection | [enum-03-select-field.md](references/enum-03-select-field.md) | +| **AutocompleteField** | Many options, supports search/filter | [enum-04-autocomplete-field.md](references/enum-04-autocomplete-field.md) | +| **RadioGroupField** | Few options (<=4), all visible at once | [enum-05-radio-group-field.md](references/enum-05-radio-group-field.md) | +| **CheckboxListField** | Multi-select (form state is an array) | [enum-06-checkbox-list-field.md](references/enum-06-checkbox-list-field.md) | + +## Helper References + +| Helper | Purpose | Reference | +| ------------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `createTranslatableEnum` | Factory function for all translatable enums | [enum-helper-create-translatable-enum.md](references/enum-helper-create-translatable-enum.md) | +| `EnumChip` | Generic chip wrapper with dropdown menu | [enum-helper-enum-chip.md](references/enum-helper-enum-chip.md) | +| `ChipIcon` | Loading/dropdown state icon for chips | [enum-helper-chip-icon.md](references/enum-helper-chip-icon.md) | +| `recordToOptions` | Convert record to options array | [enum-helper-record-to-options.md](references/enum-helper-record-to-options.md) | + +## Auto-detection + +When working on a feature and a GQL enum type from `@src/graphql.generated` is needed in admin code, proactively check whether a translatable enum file exists for it. If not, create one as part of the feature work without waiting for an explicit request. + +## Cross-skill Integration + +- The **comet-admin-form** skill uses enum field components (SelectField, RadioGroupField, CheckboxListField) in forms. It will call this skill to create missing enum fields. +- The **comet-admin-datagrid** skill uses chip components in grid columns. It will call this skill to create missing chips. diff --git a/package-skills/comet-admin-translatable-enum/references/enum-00-translatable.md b/package-skills/comet-admin-translatable-enum/references/enum-00-translatable.md new file mode 100644 index 0000000000..28ae2a7967 --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-00-translatable.md @@ -0,0 +1,95 @@ +# Translatable Enum Component + +**Always read this file first** — it is the base pattern for every enum component. + +## File Location + +``` +{domain}/components/{camelCaseName}/{EnumName} +``` + +Examples: + +- `LocationStatus` → `location/components/locationStatus/LocationStatus` +- `ProductCategory` → `product/components/productCategory/ProductCategory` + +If the domain is unclear, ask the user. + +## Template + +```ts +import { createTranslatableEnum } from "@src/common/components/enums/createTranslatableEnum"; +import { type GQL{EnumName} } from "@src/graphql.generated"; +import { defineMessage } from "react-intl"; + +const { + messageDescriptorMap, + formattedMessageMap, + Component: {EnumName}, +} = createTranslatableEnum({ + {ENUM_VALUE}: defineMessage({ defaultMessage: "{GermanLabel}", id: "{domain}.{camelCaseName}.{camelCaseValue}" }), + // ... one entry per enum value +}); + +export { {EnumName}, formattedMessageMap as {camelCaseName}FormattedMessageMap, messageDescriptorMap as {camelCaseName}MessageDescriptorMap }; +``` + +## Concrete Example + +For `enum LocationStatus { ACTIVE INACTIVE }`: + +```ts +import { createTranslatableEnum } from "@src/common/components/enums/createTranslatableEnum"; +import { type GQLLocationStatus } from "@src/graphql.generated"; +import { defineMessage } from "react-intl"; + +const { + messageDescriptorMap, + formattedMessageMap, + Component: LocationStatus, +} = createTranslatableEnum({ + ACTIVE: defineMessage({ defaultMessage: "Aktiv", id: "location.locationStatus.active" }), + INACTIVE: defineMessage({ defaultMessage: "Inaktiv", id: "location.locationStatus.inactive" }), +}); + +export { LocationStatus, formattedMessageMap as locationStatusFormattedMessageMap, messageDescriptorMap as locationStatusMessageDescriptorMap }; +``` + +## Translations + +- Derive sensible labels from the enum value name (e.g. `ACTIVE` → `"Active"`, `INACTIVE` → `"Inactive"`) +- If the user provides translations in the prompt, use those instead +- The `id` should have semantic meaning derived from the file path. Use the component's domain path to build the id (e.g. for a file at `location/components/locationStatus/LocationStatus` → `location.locationStatus.active`) + +## Storybook Story + +Place in `__stories__/` subfolder next to the component file. One story per enum value. + +**Story title convention:** `{Domain}/Components/Enums/{camelCaseName}/{ComponentName}` + +```tsx +// File: admin/src/location/components/locationStatus/__stories__/LocationStatus.stories.tsx + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { LocationStatus } from "../LocationStatus"; + +type Story = StoryObj; +const meta: Meta = { + component: LocationStatus, + title: "Common/Components/Enums/locationStatus/LocationStatus", +}; +export default meta; + +export const Active: Story = { + args: { + value: "ACTIVE", + }, +}; + +export const Inactive: Story = { + args: { + value: "INACTIVE", + }, +}; +``` diff --git a/package-skills/comet-admin-translatable-enum/references/enum-01-chip.md b/package-skills/comet-admin-translatable-enum/references/enum-01-chip.md new file mode 100644 index 0000000000..c2ceb1e27f --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-01-chip.md @@ -0,0 +1,184 @@ +# Chip Component + +Renders the enum value as a colored MUI `` with an optional dropdown menu to switch values (when `onSelectItem` is provided). + +## Prerequisites + +1. **Translatable enum** must exist — create it first using [enum-00-translatable.md](enum-00-translatable.md) if missing +2. **`EnumChip` helper** must exist — search for it in the project. Create from [enum-helper-enum-chip.md](enum-helper-enum-chip.md) if missing +3. **`ChipIcon` helper** must exist — search for it in the project. Create from [enum-helper-chip-icon.md](enum-helper-chip-icon.md) if missing + +## File Location + +``` +{domain}/components/{camelCaseName}Chip/{EnumName}Chip +``` + +Example: `LocationStatus` → `location/components/locationStatusChip/LocationStatusChip` + +## Color Conventions + +Use MUI `ChipProps["color"]` values: `"success"`, `"error"`, `"warning"`, `"info"`, `"default"`, `"primary"`, `"secondary"`. + +| Enum value pattern | Color | +| ---------------------------------- | ----------- | +| `ACTIVE`, `PUBLISHED`, `ENABLED` | `"success"` | +| `INACTIVE`, `DISABLED`, `ARCHIVED` | `"error"` | +| `DRAFT`, `PENDING` | `"warning"` | +| `SCHEDULED` | `"info"` | + +Ask the user if semantics are unclear. + +## Template + +```tsx +import { Chip } from "@mui/material"; +import { EnumChip, type EnumChipProps } from "@src/common/components/enums/enumChip/EnumChip"; +import { {EnumName}, {camelCaseName}FormattedMessageMap } from "{enumImportPath}"; +import { type GQL{EnumName} } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +import { ChipIcon } from "{chipIconRelativePath}"; + +type {EnumName}ChipProps = Pick, "loading" | "onSelectItem" | "value">; + +const {camelCaseName}SortOrder: GQL{EnumName}[] = [{ENUM_VALUE_1}, {ENUM_VALUE_2}]; + +export const {EnumName}Chip: FunctionComponent<{EnumName}ChipProps> = ({ loading, onSelectItem, value }) => { + return ( + + chipMap={{ + {ENUM_VALUE_1}: (chipProps) => ( + } + label={<{EnumName} value="{ENUM_VALUE_1}" />} + onClick={chipProps.onClick} + variant="filled" + /> + ), + // ... one entry per enum value + }} + formattedMessageMap={{camelCaseName}FormattedMessageMap} + loading={loading} + onSelectItem={onSelectItem} + sortOrder={{camelCaseName}SortOrder} + value={value} + /> + ); +}; +``` + +## Concrete Example + +```tsx +// File: admin/src/location/components/locationStatusChip/LocationStatusChip.tsx + +import { Chip } from "@mui/material"; +import { EnumChip, type EnumChipProps } from "@src/common/components/enums/enumChip/EnumChip"; +import { LocationStatus, locationStatusFormattedMessageMap } from "@src/common/components/enums/locationStatus/locationStatus/LocationStatus"; +import { type GQLLocationStatus } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +import { ChipIcon } from "../../chipIcon/ChipIcon"; + +type LocationStatusChipProps = Pick, "loading" | "onSelectItem" | "value">; + +const entityStatusOrder: GQLLocationStatus[] = ["ACTIVE", "INACTIVE"]; + +export const LocationStatusChip: FunctionComponent = ({ loading, onSelectItem, value }) => { + return ( + + chipMap={{ + ACTIVE: (chipProps) => { + return ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ); + }, + INACTIVE: (chipProps) => { + return ( + } + label={} + onClick={chipProps.onClick} + variant="filled" + /> + ); + }, + }} + formattedMessageMap={locationStatusFormattedMessageMap} + loading={loading} + onSelectItem={onSelectItem} + sortOrder={entityStatusOrder} + value={value} + /> + ); +}; +``` + +## Storybook Story + +Per-value stories + `Loading` + one clickable story per value. + +```tsx +// File: admin/src/location/components/locationStatusChip/__stories__/LocationStatusChip.stories.tsx + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { LocationStatusChip } from "../LocationStatusChip"; + +type Story = StoryObj; + +const meta: Meta = { + component: LocationStatusChip, + title: "Common/Components/Enums/locationStatus/LocationStatusChip", +}; +export default meta; + +export const Active: Story = { + args: { + value: "ACTIVE", + }, +}; + +export const Inactive: Story = { + args: { + value: "INACTIVE", + }, +}; + +export const Loading: Story = { + args: { + loading: true, + value: "ACTIVE", + }, +}; + +export const ActiveClickable: Story = { + args: { + onSelectItem: (status) => { + alert(`Selected Status: ${status}`); + }, + value: "ACTIVE", + }, +}; + +export const InactiveClickable: Story = { + args: { + onSelectItem: (status) => { + alert(`Selected Status: ${status}`); + }, + value: "INACTIVE", + }, +}; +``` diff --git a/package-skills/comet-admin-translatable-enum/references/enum-02-editable-chip.md b/package-skills/comet-admin-translatable-enum/references/enum-02-editable-chip.md new file mode 100644 index 0000000000..d286f8a7f4 --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-02-editable-chip.md @@ -0,0 +1,204 @@ +# Editable Chip Component + +Wraps the enum chip with an Apollo query and mutation, allowing the user to change the enum value for a specific entity directly from the chip dropdown. + +**Naming convention:** `{EnumName}ChipEditableFor{EntityName}` +Example: `LocationStatus` on `Location` → `LocationStatusChipEditableForLocation` + +## Prerequisites + +1. **Chip component** must exist — create it first using [enum-01-chip.md](enum-01-chip.md) if missing +2. **Auto-detect query and mutation** from `api/schema.gql` — find the entity query, update mutation, and input type structure + +## File Location + +``` +{domain}/components/{camelCaseName}ChipEditableFor{EntityName}/{EnumName}ChipEditableFor{EntityName} +``` + +## Query & Mutation + +```ts +export const {camelCaseName}For{EntityName}Query = gql` + query {PascalCaseName}For{EntityName}($id: ID!) { + {entityQuery}(id: $id) { + id + {enumFieldName} + } + } +`; + +export const update{EntityName}{EnumName}Mutation = gql` + mutation Update{EntityName}{EnumName}($id: ID!, ${enumFieldName}: {EnumName}!) { + {updateMutation}(id: $id, input: { {enumFieldName}: ${enumFieldName} }) { + id + {enumFieldName} + } + } +`; +``` + +## Component Template + +```tsx +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 { {EnumName}Chip } from "{chipImportPath}"; +import { type FunctionComponent } from "react"; + +import { {camelCaseName}For{EntityName}Query, update{EntityName}{EnumName}Mutation } from "./{EnumName}ChipEditableFor{EntityName}.gql"; +import { + type GQL{PascalCaseName}For{EntityName}Query, + type GQL{PascalCaseName}For{EntityName}QueryVariables, + type GQLUpdate{EntityName}{EnumName}Mutation, + type GQLUpdate{EntityName}{EnumName}MutationVariables, +} from "./{EnumName}ChipEditableFor{EntityName}.gql.generated"; + +type {EnumName}ChipEditableFor{EntityName}Props = { + {entityId}: string; +}; + +export const {EnumName}ChipEditableFor{EntityName}: FunctionComponent<{EnumName}ChipEditableFor{EntityName}Props> = ({ {entityId} }) => { + const { data, loading, error } = useQuery( + {camelCaseName}For{EntityName}Query, + { + variables: { id: {entityId} }, + context: LocalErrorScopeApolloContext, + }, + ); + const [updateMutation, { loading: updateLoading }] = useMutation< + GQLUpdate{EntityName}{EnumName}Mutation, + GQLUpdate{EntityName}{EnumName}MutationVariables + >(update{EntityName}{EnumName}Mutation); + + if (error) { + return ( + } variant="light"> + + + ); + } + return data?.{entityQuery}.{enumFieldName} ? ( + <{EnumName}Chip + value={data.{entityQuery}.{enumFieldName}} + loading={loading || updateLoading} + onSelectItem={({enumFieldName}) => { + updateMutation({ variables: { id: {entityId}, {enumFieldName} } }); + }} + /> + ) : null; +}; +``` + +## DataGrid Integration + +When using an editable chip inside a DataGrid column with `onRowClick`, you **must** stop event propagation on both `onClick` and `onMouseDown`. Otherwise the DataGrid captures the events for cell selection and row navigation, making the chip unclickable or requiring a double click. + +```tsx +{ + field: "status", + headerName: "Status", + renderCell: ({ row }) => ( + e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> + <{EnumName}ChipEditableFor{EntityName} {entityId}={row.id} /> + + ), +} +``` + +- `onMouseDown` stopPropagation prevents the DataGrid from selecting the cell on first click +- `onClick` stopPropagation prevents `onRowClick` from firing and navigating away + +## Concrete Example + +### Query & Mutation + +```ts +export const locationStatusForLocationQuery = gql` + query LocationStatusForLocation($id: ID!) { + location(id: $id) { + id + status + } + } +`; + +export const updateLocationStatusForLocationMutation = gql` + mutation UpdateLocationStatusForLocation($id: ID!, $status: LocationStatus!) { + updateLocation(id: $id, input: { status: $status }) { + id + status + } + } +`; +``` + +### Component + +```tsx +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 { LocationStatusChip } from "@src/common/components/enums/locationStatus/locationStatusChip/LocationStatusChip"; +import { type FunctionComponent } from "react"; + +import { locationStatusForLocationQuery, updateLocationStatusForLocationMutation } from "./LocationStatusChipEditableForLocation.gql"; +import { + type GQLLocationStatusForLocationQuery, + type GQLLocationStatusForLocationQueryVariables, + type GQLUpdateLocationStatusForLocationMutation, + type GQLUpdateLocationStatusForLocationMutationVariables, +} from "./LocationStatusChipEditableForLocation.gql.generated"; + +type LocationStatusChipEditableForLocationProps = { + locationId: string; +}; + +export const LocationStatusChipEditableForLocation: FunctionComponent = ({ locationId }) => { + const { data, loading, error } = useQuery( + locationStatusForLocationQuery, + { + variables: { + id: locationId, + }, + context: LocalErrorScopeApolloContext, + }, + ); + const [updateMutation, { loading: updateLoading }] = useMutation< + GQLUpdateLocationStatusForLocationMutation, + GQLUpdateLocationStatusForLocationMutationVariables + >(updateLocationStatusForLocationMutation); + + if (error) { + return ( + + + + } + variant="light" + > + + + ); + } + return data?.location.status ? ( + { + updateMutation({ + variables: { + id: locationId, + status: status, + }, + }); + }} + /> + ) : null; +}; +``` diff --git a/package-skills/comet-admin-translatable-enum/references/enum-03-select-field.md b/package-skills/comet-admin-translatable-enum/references/enum-03-select-field.md new file mode 100644 index 0000000000..05bdd0f88f --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-03-select-field.md @@ -0,0 +1,106 @@ +# SelectField + +Default single-value enum selection field for Final Form. + +## Prerequisites + +1. **Translatable enum** must exist — create using [enum-00-translatable.md](enum-00-translatable.md) if missing +2. **`recordToOptions` helper** must exist — search for it in the project. Create from [enum-helper-record-to-options.md](enum-helper-record-to-options.md) if missing + +## File Location + +``` +{domain}/components/{camelCaseName}SelectField/{EnumName}SelectField +``` + +## Template + +```tsx +import { SelectField, type SelectFieldProps } from "@comet/admin"; +import { {camelCaseName}FormattedMessageMap } from "{enumImportPath}"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions"; +import { type GQL{EnumName} } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +export type {EnumName}FormState = GQL{EnumName}; + +type {EnumName}SelectFieldProps = Omit, "options">; + +export const {EnumName}SelectField: FunctionComponent<{EnumName}SelectFieldProps> = ({ name, ...restProps }) => { + return ; +}; +``` + +## Concrete Example + +```tsx +// File: admin/src/location/components/locationStatusSelectField/LocationStatusSelectField.tsx + +import { SelectField, type SelectFieldProps } from "@comet/admin"; +import { locationStatusFormattedMessageMap } from "@src/common/components/enums/locationStatus/locationStatus/LocationStatus"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions"; +import { type GQLLocationStatus } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +export type LocationStatusFormState = GQLLocationStatus; + +type LocationStatusSelectFieldProps = Omit, "options">; + +export const LocationStatusSelectField: FunctionComponent = ({ name, ...restProps }) => { + return ; +}; +``` + +## Storybook Story + +Single `Default` story wrapping in `` with ``. + +```tsx +// File: admin/src/location/components/locationStatusSelectField/__stories__/LocationStatusSelectField.stories.tsx + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { FinalForm, FinalFormDebug } from "@comet/admin"; +import { FormattedMessage } from "react-intl"; +import { LocationStatusFormState, LocationStatusSelectField } from "../LocationStatusSelectField"; + +type Story = StoryObj; +const config: Meta = { + component: LocationStatusSelectField, + title: "common/components/enums/locationStatus/LocationStatusSelectField", +}; +export default config; + +export const Default: Story = { + render: () => { + interface FormValues { + status: LocationStatusFormState; + } + return ( + + initialValues={{}} + mode="edit" + onSubmit={() => { + // not handled + }} + subscription={{ values: true }} + > + {({ values }) => { + return ( + <> + } + fullWidth + variant="horizontal" + /> + + + + ); + }} + + ); + }, +}; +``` diff --git a/package-skills/comet-admin-translatable-enum/references/enum-04-autocomplete-field.md b/package-skills/comet-admin-translatable-enum/references/enum-04-autocomplete-field.md new file mode 100644 index 0000000000..c9cee7f430 --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-04-autocomplete-field.md @@ -0,0 +1,164 @@ +# AutocompleteField + +Enum selection with search/filter support. Returns `{ value, label: string }`. + +## Prerequisites + +1. **Translatable enum** must exist — create using [enum-00-translatable.md](enum-00-translatable.md) if missing +2. **`useAutocompleteOptions` hook** must exist — search for it in the project. If missing, ask the user — no reference implementation is bundled + +## File Location + +``` +{domain}/components/{camelCaseName}AutocompleteField/{EnumName}AutocompleteField +``` + +## Template + +```tsx +import { AutocompleteField, type AutocompleteFieldProps } from "@comet/admin"; +import { {camelCaseName}MessageDescriptorMap } from "{enumImportPath}"; +import { useAutocompleteOptions } from "@src/common/components/enums/useAutocompleteOptions"; +import { type GQL{EnumName} } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +export type {EnumName}FormState = GQL{EnumName}; + +type {EnumName}AutocompleteFieldOption = { value: GQL{EnumName}; label: string }; + +type {EnumName}AutocompleteFieldProps = Omit< + AutocompleteFieldProps<{EnumName}AutocompleteFieldOption, false, false, false>, + "options" | "getOptionLabel" +>; + +export const {EnumName}AutocompleteField: FunctionComponent<{EnumName}AutocompleteFieldProps> = ({ name, ...restProps }) => { + const options = useAutocompleteOptions({camelCaseName}MessageDescriptorMap); + return option.label} />; +}; +``` + +## Concrete Example + +```tsx +// File: admin/src/location/components/locationStatusAutocompleteField/LocationStatusAutocompleteField.tsx + +import { AutocompleteField, type AutocompleteFieldProps } from "@comet/admin"; +import { locationStatusMessageDescriptorMap } from "@src/common/components/enums/locationStatus/locationStatus/LocationStatus"; +import { useAutocompleteOptions } from "@src/common/components/enums/useAutocompleteOptions"; +import { type GQLLocationStatus } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +export type LocationStatusFormState = GQLLocationStatus; + +type LocationStatusAutocompleteFieldOption = { + value: GQLLocationStatus; + label: string; +}; + +type LocationStatusAutocompleteFieldProps = Omit< + AutocompleteFieldProps, + "options" | "getOptionLabel" +>; + +export const LocationStatusAutocompleteField: FunctionComponent = ({ name, ...restProps }) => { + const options = useAutocompleteOptions(locationStatusMessageDescriptorMap); + + return ( + { + return option.label; + }} + /> + ); +}; +``` + +## Storybook Story + +`Default` story (single value) + `Multiple` story (array value, `multiple` prop). + +```tsx +// File: admin/src/location/components/locationStatusAutocompleteField/__stories__/LocationStatusAutocompleteField.stories.tsx + +import { Meta, StoryObj } from "@storybook/react-vite"; +import { FinalForm, FinalFormDebug } from "@comet/admin"; +import { FormattedMessage } from "react-intl"; +import { LocationStatusAutocompleteField, LocationStatusFormState } from "../LocationStatusAutocompleteField"; + +type Story = StoryObj; +const config: Meta = { + component: LocationStatusAutocompleteField, + title: "common/components/enums/locationStatus/LocationStatusAutocompleteField", +}; +export default config; + +export const Default: Story = { + render: () => { + interface FormValues { + status: LocationStatusFormState; + } + return ( + + initialValues={{}} + mode="edit" + onSubmit={() => { + // not handled + }} + subscription={{ values: true }} + > + {({ values }) => { + return ( + <> + } + fullWidth + variant="horizontal" + /> + + + + ); + }} + + ); + }, +}; + +export const Multiple: Story = { + render: () => { + interface FormValues { + statuses: LocationStatusFormState[]; + } + return ( + + initialValues={{}} + mode="edit" + onSubmit={() => { + // not handled + }} + subscription={{ values: true }} + > + {({ values }) => { + return ( + <> + } + fullWidth + variant="horizontal" + multiple + /> + + + + ); + }} + + ); + }, +}; +``` diff --git a/package-skills/comet-admin-translatable-enum/references/enum-05-radio-group-field.md b/package-skills/comet-admin-translatable-enum/references/enum-05-radio-group-field.md new file mode 100644 index 0000000000..c1c8cf451b --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-05-radio-group-field.md @@ -0,0 +1,108 @@ +# RadioGroupField + +All options visible at once. Best for few options (<=4). + +## Prerequisites + +1. **Translatable enum** must exist — create using [enum-00-translatable.md](enum-00-translatable.md) if missing +2. **`recordToOptions` helper** must exist — search for it in the project. Create from [enum-helper-record-to-options.md](enum-helper-record-to-options.md) if missing + +## File Location + +``` +{domain}/components/{camelCaseName}RadioGroupField/{EnumName}RadioGroupField +``` + +## Template + +Same shape as SelectField, swap `SelectField` → `RadioGroupField`: + +```tsx +import { RadioGroupField, type SelectFieldProps } from "@comet/admin"; +import { {camelCaseName}FormattedMessageMap } from "{enumImportPath}"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions"; +import { type GQL{EnumName} } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +export type {EnumName}FormState = GQL{EnumName}; + +type {EnumName}RadioGroupFieldProps = Omit, "options">; + +export const {EnumName}RadioGroupField: FunctionComponent<{EnumName}RadioGroupFieldProps> = ({ name, ...restProps }) => { + return ; +}; +``` + +## Concrete Example + +```tsx +// File: admin/src/location/components/locationStatusRadioGroupField/LocationStatusRadioGroupField.tsx + +import { RadioGroupField, type SelectFieldProps } from "@comet/admin"; +import { locationStatusFormattedMessageMap } from "@src/common/components/enums/locationStatus/locationStatus/LocationStatus"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions"; +import { type GQLLocationStatus } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +export type LocationStatusFormState = GQLLocationStatus; + +type LocationStatusRadioGroupFieldProps = Omit, "options">; + +export const LocationStatusRadioGroupField: FunctionComponent = ({ name, ...restProps }) => { + return ; +}; +``` + +## Storybook Story + +Single `Default` story wrapping in `` with ``. + +```tsx +// File: admin/src/location/components/locationStatusRadioGroupField/__stories__/LocationStatusRadioGroupField.stories.tsx + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { FinalForm, FinalFormDebug } from "@comet/admin"; +import { FormattedMessage } from "react-intl"; +import { LocationStatusFormState, LocationStatusRadioGroupField } from "../LocationStatusRadioGroupField"; + +type Story = StoryObj; +const config: Meta = { + component: LocationStatusRadioGroupField, + title: "common/components/enums/locationStatus/LocationStatusRadioGroupField", +}; +export default config; + +export const Default: Story = { + render: () => { + interface FormValues { + status: LocationStatusFormState; + } + return ( + + initialValues={{}} + mode="edit" + onSubmit={() => { + // not handled + }} + subscription={{ values: true }} + > + {({ values }) => { + return ( + <> + } + fullWidth + variant="horizontal" + /> + + + + ); + }} + + ); + }, +}; +``` diff --git a/package-skills/comet-admin-translatable-enum/references/enum-06-checkbox-list-field.md b/package-skills/comet-admin-translatable-enum/references/enum-06-checkbox-list-field.md new file mode 100644 index 0000000000..132be0009d --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-06-checkbox-list-field.md @@ -0,0 +1,108 @@ +# CheckboxListField + +Multi-select enum field. Form state is an array. + +## Prerequisites + +1. **Translatable enum** must exist — create using [enum-00-translatable.md](enum-00-translatable.md) if missing +2. **`recordToOptions` helper** must exist — search for it in the project. Create from [enum-helper-record-to-options.md](enum-helper-record-to-options.md) if missing + +## File Location + +``` +{domain}/components/{camelCaseName}CheckboxListField/{EnumName}CheckboxListField +``` + +## Template + +Same shape as SelectField, swap `SelectField` → `CheckboxListField`: + +```tsx +import { CheckboxListField, type SelectFieldProps } from "@comet/admin"; +import { {camelCaseName}FormattedMessageMap } from "{enumImportPath}"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions"; +import { type GQL{EnumName} } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +export type {EnumName}FormState = GQL{EnumName}; + +type {EnumName}CheckboxListFieldProps = Omit, "options">; + +export const {EnumName}CheckboxListField: FunctionComponent<{EnumName}CheckboxListFieldProps> = ({ name, ...restProps }) => { + return ; +}; +``` + +## Concrete Example + +```tsx +// File: admin/src/location/components/locationStatusCheckboxListField/LocationStatusCheckboxListField.tsx + +import { CheckboxListField, type SelectFieldProps } from "@comet/admin"; +import { locationStatusFormattedMessageMap } from "@src/common/components/enums/locationStatus/locationStatus/LocationStatus"; +import { recordToOptions } from "@src/common/components/enums/recordToOptions"; +import { type GQLLocationStatus } from "@src/graphql.generated"; +import { type FunctionComponent } from "react"; + +export type LocationStatusFormState = GQLLocationStatus; + +type LocationStatusCheckboxListFieldProps = Omit, "options">; + +export const LocationStatusCheckboxListField: FunctionComponent = ({ name, ...restProps }) => { + return ; +}; +``` + +## Storybook Story + +Single `Default` story wrapping in `` with ``. + +```tsx +// File: admin/src/location/components/locationStatusCheckboxListField/__stories__/LocationStatusCheckboxListField.stories.tsx + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { FinalForm, FinalFormDebug } from "@comet/admin"; +import { FormattedMessage } from "react-intl"; +import { LocationStatusCheckboxListField, LocationStatusFormState } from "../LocationStatusCheckboxListField"; + +type Story = StoryObj; +const config: Meta = { + component: LocationStatusCheckboxListField, + title: "common/components/enums/locationStatus/LocationStatusCheckboxListField", +}; +export default config; + +export const Default: Story = { + render: () => { + interface FormValues { + status: LocationStatusFormState; + } + return ( + + initialValues={{}} + mode="edit" + onSubmit={() => { + // not handled + }} + subscription={{ values: true }} + > + {({ values }) => { + return ( + <> + } + fullWidth + variant="horizontal" + /> + + + + ); + }} + + ); + }, +}; +``` diff --git a/package-skills/comet-admin-translatable-enum/references/enum-helper-chip-icon.md b/package-skills/comet-admin-translatable-enum/references/enum-helper-chip-icon.md new file mode 100644 index 0000000000..f9c76a0aa7 --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-helper-chip-icon.md @@ -0,0 +1,27 @@ +# Helper: ChipIcon + +Icon component for enum chips showing loading/dropdown state. Search for it in the project. + +Create this file if it does not exist. + +```tsx +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/package-skills/comet-admin-translatable-enum/references/enum-helper-create-translatable-enum.md b/package-skills/comet-admin-translatable-enum/references/enum-helper-create-translatable-enum.md new file mode 100644 index 0000000000..df07920074 --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-helper-create-translatable-enum.md @@ -0,0 +1,40 @@ +# Helper: createTranslatableEnum + +Core factory function that all translatable enums use. Search for it in the project. + +Create this file if it does not exist. + +```tsx +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/package-skills/comet-admin-translatable-enum/references/enum-helper-enum-chip.md b/package-skills/comet-admin-translatable-enum/references/enum-helper-enum-chip.md new file mode 100644 index 0000000000..7b5bf299eb --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-helper-enum-chip.md @@ -0,0 +1,58 @@ +# Helper: EnumChip + +Generic chip wrapper with dropdown menu. Search for it in the project. + +Create this file if it does not exist. + +```tsx +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/package-skills/comet-admin-translatable-enum/references/enum-helper-record-to-options.md b/package-skills/comet-admin-translatable-enum/references/enum-helper-record-to-options.md new file mode 100644 index 0000000000..2febc248ac --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-helper-record-to-options.md @@ -0,0 +1,21 @@ +# Helper: recordToOptions + +Converts a `Record` to an options array for SelectField/RadioGroupField/CheckboxListField. Search for it in the project. + +Create this file if it does not exist. + +```ts +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, + })); +} +``` From 896401b46d779eb5ba66a2ddf19af063894d71bf Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 13:38:13 +0100 Subject: [PATCH 03/12] add comet-admin-datagrid skill --- package-skills/comet-admin-datagrid/SKILL.md | 100 ++++++++ .../references/grid-00-standard-paginated.md | 228 ++++++++++++++++++ .../references/grid-01-non-paginated.md | 59 +++++ .../references/grid-02-row-reordering.md | 115 +++++++++ .../references/grid-03-sub-entity.md | 56 +++++ .../references/grid-04-select.md | 68 ++++++ .../references/grid-05-excel-export.md | 96 ++++++++ .../references/grid-06-responsive-columns.md | 71 ++++++ .../references/grid-07-initial-sort-filter.md | 39 +++ .../grid-08-external-filter-prop.md | 51 ++++ .../references/grid-09-content-scope.md | 48 ++++ .../references/grid-col-def-01-string.md | 27 +++ .../references/grid-col-def-02-boolean.md | 10 + .../references/grid-col-def-03-number.md | 43 ++++ .../references/grid-col-def-04-datetime.md | 29 +++ .../references/grid-col-def-05-enum.md | 120 +++++++++ .../references/grid-col-def-06-many-to-one.md | 18 ++ .../grid-col-def-07-many-to-many.md | 21 ++ .../references/grid-col-def-08-one-to-many.md | 21 ++ .../grid-col-def-09-array-of-scalars.md | 16 ++ .../grid-col-def-10-nested-object.md | 36 +++ .../references/grid-col-def-11-file-upload.md | 86 +++++++ .../references/grid-col-def-12-id.md | 16 ++ .../grid-col-def-13-relation-filter.md | 137 +++++++++++ .../references/grid-toolbar-00-standard.md | 35 +++ .../grid-toolbar-01-excel-export.md | 68 ++++++ .../grid-toolbar-02-row-reordering.md | 28 +++ .../references/grid-toolbar-03-select.md | 26 ++ 28 files changed, 1668 insertions(+) create mode 100644 package-skills/comet-admin-datagrid/SKILL.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-00-standard-paginated.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-01-non-paginated.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-02-row-reordering.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-03-sub-entity.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-04-select.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-05-excel-export.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-06-responsive-columns.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-07-initial-sort-filter.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-08-external-filter-prop.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-09-content-scope.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-01-string.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-02-boolean.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-03-number.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-04-datetime.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-05-enum.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-06-many-to-one.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-07-many-to-many.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-08-one-to-many.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-09-array-of-scalars.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-10-nested-object.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-11-file-upload.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-12-id.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-col-def-13-relation-filter.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-toolbar-00-standard.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-toolbar-01-excel-export.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-toolbar-02-row-reordering.md create mode 100644 package-skills/comet-admin-datagrid/references/grid-toolbar-03-select.md diff --git a/package-skills/comet-admin-datagrid/SKILL.md b/package-skills/comet-admin-datagrid/SKILL.md new file mode 100644 index 0000000000..0ebdf904a0 --- /dev/null +++ b/package-skills/comet-admin-datagrid/SKILL.md @@ -0,0 +1,100 @@ +--- +name: comet-admin-datagrid +description: | + Generates and modifies server-side MUI DataGrid components for a Comet DXP admin project. All filtering, sorting, searching, and pagination are handled server-side using Apollo Client and @comet/admin hooks. + TRIGGER when: user says "create a datagrid for X", "add a grid for X", "build a list view for X", "generate a datagrid", or any similar phrase requesting a data table or grid component. Also trigger when the user asks to add, remove, or modify columns, filters, toolbar actions, or any other aspect of an existing DataGrid. +--- + +# Comet DataGrid Skill + +Generate server-side DataGrid components by reading the GraphQL schema, determining columns, and producing the grid files following the patterns in `references/`. + +## Prerequisites + +1. **Read the GraphQL schema** for the target entity to determine: list query signature (`filter`, `limit`, `offset`, `search`, `sort`), paginated return type, available fields, and whether a `deleteXxx` mutation exists. +2. **Check MUI DataGrid package** in `admin/package.json` — use `DataGridPro` unless `DataGridPremium` is already used or requested. +3. **Confirm output path** with the user if not obvious from context. + +## Core Imports + +| Import | Source | Purpose | +| -------------------------- | -------------- | -------------------------------------------------- | +| `useDataGridRemote` | `@comet/admin` | Server-side pagination, sorting, filtering | +| `useDataGridUrlState` | `@comet/admin` | Client-side state for non-paginated grids | +| `usePersistentColumnState` | `@comet/admin` | Persist column visibility/order | +| `useBufferedRowCount` | `@comet/admin` | Prevent flickering row count during loading | +| `useDataGridExcelExport` | `@comet/admin` | Excel export hook | +| `muiGridFilterToGql` | `@comet/admin` | Convert MUI filter model to GQL filter/search | +| `muiGridSortToGql` | `@comet/admin` | Convert MUI sort model to GQL sort | +| `GridColDef` | `@comet/admin` | Typed column definition | +| `GridCellContent` | `@comet/admin` | Rich cell with primary/secondary text and icon | +| `CrudContextMenu` | `@comet/admin` | Delete action context menu | +| `CrudMoreActionsMenu` | `@comet/admin` | Toolbar overflow menu (e.g. excel export) | +| `DataGridToolbar` | `@comet/admin` | Toolbar wrapper for DataGrid | +| `GridFilterButton` | `@comet/admin` | Opens column filter panel | +| `FillSpace` | `@comet/admin` | Flex spacer between toolbar items | +| `Tooltip` | `@comet/admin` | Tooltip for column header info icons | +| `ExportApi` | `@comet/admin` | Type for excel export API | +| `messages` | `@comet/admin` | Pre-defined i18n messages (e.g. `downloadAsExcel`) | +| `renderStaticSelectCell` | `@comet/admin` | Render function for static select (enum) columns | +| `dataGridDateTimeColumn` | `@comet/admin` | Spread into DateTime columns | +| `dataGridDateColumn` | `@comet/admin` | Spread into LocalDate columns | +| `dataGridManyToManyColumn` | `@comet/admin` | Spread into ManyToMany columns | +| `dataGridOneToManyColumn` | `@comet/admin` | Spread into OneToMany columns | +| `dataGridIdColumn` | `@comet/admin` | Spread into ID columns | + +## Grid Variants & Features + +**Always read [grid-00-standard-paginated.md](references/grid-00-standard-paginated.md) first** — it is the base pattern. Then read any applicable variant/feature files: + +| Variant / Feature | When to use | State hook | Key differences | Reference | +| ----------------------------- | ----------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| **Standard paginated** (base) | Query returns `{ nodes, totalCount }` with `offset`/`limit` | `useDataGridRemote` | Default pattern with server-side sort/filter/search | [grid-00-standard-paginated.md](references/grid-00-standard-paginated.md) | +| **Non-paginated** | Query returns flat `[Entity!]!` list | `useDataGridUrlState` | No offset/limit/filter/search/sort vars, no rowCount | [grid-01-non-paginated.md](references/grid-01-non-paginated.md) | +| **Row reordering** | Entity has `position` field, drag-and-drop ordering | `useDataGridRemote` | Fixed sort by position, `rowReordering`, `hideFooterPagination`, no filter/search | [grid-02-row-reordering.md](references/grid-02-row-reordering.md) | +| **Sub-entity** | Grid scoped to a parent (e.g. variants of a product) | `useDataGridRemote` | Parent ID as required prop, forwarded to query vars | [grid-03-sub-entity.md](references/grid-03-sub-entity.md) | +| **Select / Checkbox** | Picker dialog for selecting entities | `useDataGridRemote` | `checkboxSelection`, no actions column, no delete | [grid-04-select.md](references/grid-04-select.md) | +| **Excel export** | User needs to export grid data | any | `useDataGridExcelExport`, `CrudMoreActionsMenu` in toolbar | [grid-05-excel-export.md](references/grid-05-excel-export.md) | +| **Responsive columns** | Different layouts for mobile vs desktop | any | `visible: theme.breakpoints.up/down()`, overview column | [grid-06-responsive-columns.md](references/grid-06-responsive-columns.md) | +| **Initial sort/filter** | Grid should start with pre-applied sort or filter | `useDataGridRemote` | `initialSort` / `initialFilter` options | [grid-07-initial-sort-filter.md](references/grid-07-initial-sort-filter.md) | +| **External filter prop** | Grid filtered by parent context | any | `filter` prop combined via `{ and: [gqlFilter, filter] }` | [grid-08-external-filter-prop.md](references/grid-08-external-filter-prop.md) | +| **Content scope** | Entity query requires a `scope` argument | any | `useContentScope()` from `@comet/cms-admin`, pass `scope` to query vars | [grid-09-content-scope.md](references/grid-09-content-scope.md) | + +These can be combined — e.g. a sub-entity grid with excel export and responsive columns. + +## Key Rules + +- Ask the user which fields to show as columns, or infer sensible defaults (e.g. `title`, `status`, `createdAt`). Keep defaults minimal. +- **Enum columns always render as chips with a filterable select by default.** For every enum column: (1) search for an existing chip component (`**/Chip.tsx`) — if none exists, use the `comet-admin-translatable-enum` skill to create one first; (2) always add `type: "singleSelect"` and `valueOptions` using the `messageDescriptorMapToValueOptions` helper to make the column filterable. Import the enum's `messageDescriptorMap` from the translatable enum file. See [grid-col-def-05-enum.md](references/grid-col-def-05-enum.md) for the full pattern and helper function. Only use `renderStaticSelectCell` when the user explicitly requests it. +- ManyToOne relation columns require a custom filter component — see [grid-col-def-13-relation-filter.md](references/grid-col-def-13-relation-filter.md). +- Always define columns inside `useMemo` to ensure stable `GridColDef` references and prevent unnecessary re-renders. +- Only include fields actually used in grid columns in the GQL fragment. + +## Toolbar Variants + +| Variant | When to use | Reference | +| ------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| **Standard** | Default toolbar with search, filter, and add button | [grid-toolbar-00-standard.md](references/grid-toolbar-00-standard.md) | +| **Excel export** | Adds export action via `CrudMoreActionsMenu`, receives `exportApi` prop | [grid-toolbar-01-excel-export.md](references/grid-toolbar-01-excel-export.md) | +| **Row reordering** | No search/filter, only add button | [grid-toolbar-02-row-reordering.md](references/grid-toolbar-02-row-reordering.md) | +| **Select / Picker** | Search and filter only, no add button | [grid-toolbar-03-select.md](references/grid-toolbar-03-select.md) | + +## Column Type Reference + +Read the relevant column file based on the field type from the GraphQL schema: + +| Column Type | File | When to use | +| -------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------- | +| String | [grid-col-def-01-string.md](references/grid-col-def-01-string.md) | String/text fields (primary label, secondary) | +| Boolean | [grid-col-def-02-boolean.md](references/grid-col-def-02-boolean.md) | Boolean fields | +| Number | [grid-col-def-03-number.md](references/grid-col-def-03-number.md) | Int, Float, currency fields | +| DateTime / LocalDate | [grid-col-def-04-datetime.md](references/grid-col-def-04-datetime.md) | DateTime, LocalDate, audit timestamps | +| Enum | [grid-col-def-05-enum.md](references/grid-col-def-05-enum.md) | Enum fields (chip component or static select) | +| ManyToOne | [grid-col-def-06-many-to-one.md](references/grid-col-def-06-many-to-one.md) | ManyToOne relation fields | +| ManyToMany | [grid-col-def-07-many-to-many.md](references/grid-col-def-07-many-to-many.md) | ManyToMany / array of relations | +| OneToMany | [grid-col-def-08-one-to-many.md](references/grid-col-def-08-one-to-many.md) | OneToMany / child entity list | +| Array of Scalars | [grid-col-def-09-array-of-scalars.md](references/grid-col-def-09-array-of-scalars.md) | Array of primitive values | +| Nested Object | [grid-col-def-10-nested-object.md](references/grid-col-def-10-nested-object.md) | Nested scalar objects (with deep nesting support) | +| FileUpload | [grid-col-def-11-file-upload.md](references/grid-col-def-11-file-upload.md) | FileUpload (image, document) and DAM Image | +| ID | [grid-col-def-12-id.md](references/grid-col-def-12-id.md) | ID column display | +| Relation Filter | [grid-col-def-13-relation-filter.md](references/grid-col-def-13-relation-filter.md) | Custom filter for ManyToOne relation columns | diff --git a/package-skills/comet-admin-datagrid/references/grid-00-standard-paginated.md b/package-skills/comet-admin-datagrid/references/grid-00-standard-paginated.md new file mode 100644 index 0000000000..6fda1be221 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-00-standard-paginated.md @@ -0,0 +1,228 @@ +# Grid: Standard Paginated (Base Pattern) + +**This is the base grid pattern. Always read this file first before any variant-specific file.** + +The grid component wires together server-side data fetching (Apollo `useQuery`), MUI DataGrid rendering, and `@comet/admin` hooks for pagination, sorting, and filtering. For column definitions see the `grid-col-def-*.md` files. + +## GQL (Fragment, Query, Mutation) + +```typescript +import { gql } from "@apollo/client"; + +const sFragment = gql` + fragment sGridItem on { + id + + + // only fields actually rendered in columns + } +`; + +export const sQuery = gql` + query sGrid($offset: Int!, $limit: Int!, $sort: [Sort!]!, $search: String, $filter: Filter) { + s(offset: $offset, limit: $limit, sort: $sort, search: $search, filter: $filter) { + nodes { + ...sGridItem + } + totalCount + } + } + ${sFragment} +`; + +export const deleteMutation = gql` + mutation Delete($id: ID!) { + delete(id: $id) + } +`; +``` + +### GQL Rules + +- Fragment name must be `sGridItem` (not `sGrid`) to avoid naming collision with the query operation name. The generated type will be `GQLsGridItemFragment` +- Only include fields actually used in grid columns in the fragment +- Omit `deleteXxxMutation` if no delete mutation exists in the schema +- **Query variable types must match the schema signature exactly** — pay close attention to nullability (`!`), list wrappers (`[...]`), and default values. For example, if the schema declares `sort: [ProductSort!]!`, the query variable must be `$sort: [ProductSort!]!` (non-nullable), not `$sort: [ProductSort!]` (nullable). Always copy the exact type from `schema.gql` +- Use the exact GQL query/mutation names from the schema — do not guess +- For fragment field selection per column type, see the `grid-col-def-*.md` reference files + +## Component Template + +```tsx +import { + GQLsGridQuery, + GQLsGridQueryVariables, + GQLsGridItemFragment, + GQLDeleteMutation, + GQLDeleteMutationVariables, +} from "./sGrid.generated"; +import { useIntl } from "react-intl"; +import { FormattedNumber } from "react-intl"; +import { useApolloClient, useQuery } from "@apollo/client"; +import { + CrudContextMenu, + GridColDef, + muiGridFilterToGql, + StackLink, + useStackSwitchApi, + useBufferedRowCount, + usePersistentColumnState, + useDataGridRemote, + muiGridSortToGql, +} from "@comet/admin"; +import { IconButton } from "@mui/material"; +import { DataGridPro, DataGridProProps, GridSlotsComponent } from "@mui/x-data-grid-pro"; +import { useMemo } from "react"; +import { Edit as EditIcon } from "@comet/admin-icons"; +import { deleteMutation, sQuery } from "./sGrid.gql"; +import { sGridToolbar } from "./toolbar/sGridToolbar"; + +export function sGrid() { + const client = useApolloClient(); + const intl = useIntl(); + const dataGridProps = { + ...useDataGridRemote({ + queryParamsPrefix: "s", + }), + ...usePersistentColumnState("sGrid"), + }; + const stackSwitchApi = useStackSwitchApi(); + + const handleRowClick: DataGridProProps["onRowClick"] = (params) => { + stackSwitchApi.activatePage("edit", params.row.id); + }; + + const columns: GridColDefsGridItemFragment>[] = useMemo( + () => [ + // see grid-col-def-*.md for column type patterns + { + field: "actions", + headerName: "", + sortable: false, + filterable: false, + type: "actions", + align: "right", + pinned: "right", + width: 84, + renderCell: (params) => { + return ( + <> + + + + { + await client.mutateMutation, GQLDeleteMutationVariables>({ + mutation: deleteMutation, + variables: { id: params.row.id }, + }); + }} + refetchQueries={[sQuery]} + /> + + ); + }, + }, + ], + [intl, client], + ); + + const { filter: gqlFilter, search: gqlSearch } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const { data, loading, error } = useQuerysGridQuery, GQLsGridQueryVariables>(sQuery, { + variables: { + filter: gqlFilter, + search: gqlSearch, + sort: muiGridSortToGql(dataGridProps.sortModel, columns) ?? [], + offset: dataGridProps.paginationModel.page * dataGridProps.paginationModel.pageSize, + limit: dataGridProps.paginationModel.pageSize, + }, + }); + const rowCount = useBufferedRowCount(data?.s.totalCount); + if (error) throw error; + const rows = data?.s.nodes ?? []; + + return ( + sGridToolbar as GridSlotsComponent["toolbar"], + }} + onRowClick={handleRowClick} + /> + ); +} +``` + +## CrudContextMenu Options + +```tsx + { + /* ... */ + }} + refetchQueries={[entitiesQuery]} + // Custom delete button label: + messagesMapping={{ delete: }} + // Change delete semantics (default "delete", alternative "remove"): + deleteType="remove" +/> +``` + +## Column Header with Tooltip + +Use `renderHeader` with `GridColumnHeaderTitle` and `Tooltip` for columns that need an info icon: + +```tsx +import { GridColumnHeaderTitle } from "@mui/x-data-grid"; +import { Info as InfoIcon } from "@comet/admin-icons"; + +{ + field: "price", + renderHeader: () => ( + <> + + }> + + + + ), + headerName: intl.formatMessage({ id: "product.price", defaultMessage: "Price" }), + // ... rest of column config +} +``` + +Note: Keep `headerName` alongside `renderHeader` — it is used for accessibility and export. + +## Density + +Override row height density on the DataGridPro: + +```tsx + +``` + +## Rules + +- **`muiGridSortToGql` can return `undefined`** when no sort model is set. Since the `$sort` variable is non-nullable (`[EntitySort!]!`), always provide a fallback: `muiGridSortToGql(dataGridProps.sortModel, columns) ?? []` +- Only import what is actually used — remove unused imports +- `queryParamsPrefix` = camelCase entity plural (e.g. `"products"`) +- `usePersistentColumnState` key = PascalCase grid name (e.g. `"ProductsGrid"`) +- `actions` column is always last, `pinned: "right"`, `width: 84` +- Omit `CrudContextMenu` and `delete*` imports/types if no delete mutation exists in the schema +- Omit `onRowClick` if the entity has no edit page +- Use `DataGridPro` by default; switch to `DataGridPremium` only if the user requests premium features +- Generated types come from `./sGrid.generated` — never create this file manually +- `renderCell` can always be used for any field type when fully custom rendering is needed +- For column type patterns, see the `grid-col-def-*.md` reference files +- For grid variants, see the `grid-01` through `grid-08` reference files diff --git a/package-skills/comet-admin-datagrid/references/grid-01-non-paginated.md b/package-skills/comet-admin-datagrid/references/grid-01-non-paginated.md new file mode 100644 index 0000000000..e5ea037ffa --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-01-non-paginated.md @@ -0,0 +1,59 @@ +# Grid Variant: Non-Paginated + +Used when the GraphQL query returns a flat list (`[Entity!]!`) instead of a paginated wrapper (`{ nodes, totalCount }`). Common for simple lookup entities or fixed-size lists. + +## Template + +```tsx +import { useDataGridUrlState } from "@comet/admin"; + +export function EntitiesGrid() { + const client = useApolloClient(); + const intl = useIntl(); + // useDataGridUrlState instead of useDataGridRemote + const dataGridProps = { ...useDataGridUrlState(), ...usePersistentColumnState("EntitiesGrid") }; + + const columns: GridColDef[] = useMemo( + () => [ + // columns as normal + ], + [intl, client], + ); + + const { data, loading, error } = useQuery(entitiesQuery, { + variables: {}, + }); + if (error) throw error; + // Direct array, no .nodes or .totalCount + const rows = data?.entities ?? []; + + return ( + + ); +} +``` + +## GQL + +```graphql +query EntitiesGrid { + entities { + ...EntitiesGridItem + } +} +``` + +## Rules + +- Use `useDataGridUrlState` instead of `useDataGridRemote` +- No `offset`, `limit`, `sort`, `search`, `filter` variables in the query +- No `rowCount`, no `useBufferedRowCount` +- No `muiGridFilterToGql` or `muiGridSortToGql` +- Rows come directly from `data?.entities ?? []` (no `.nodes`) diff --git a/package-skills/comet-admin-datagrid/references/grid-02-row-reordering.md b/package-skills/comet-admin-datagrid/references/grid-02-row-reordering.md new file mode 100644 index 0000000000..34d24af83e --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-02-row-reordering.md @@ -0,0 +1,115 @@ +# Grid Variant: Row Reordering + +Used when entities have a `position` field and need drag-and-drop reordering. The grid is sorted by position, pagination is hidden, and filtering/sorting/search are disabled. + +## Template + +```tsx +import { GridRowOrderChangeParams } from "@mui/x-data-grid-pro"; + +// Additional mutation for position updates +const updateEntityPositionMutation = gql` + mutation UpdateEntityPosition($id: ID!, $input: EntityUpdateInput!) { + updateEntity(id: $id, input: $input) { + id + position + updatedAt + } + } +`; + +export function EntitiesGrid() { + const client = useApolloClient(); + const intl = useIntl(); + const dataGridProps = { + ...useDataGridRemote({ + queryParamsPrefix: "entities", + }), + ...usePersistentColumnState("EntitiesGrid"), + }; + + const handleRowOrderChange = async ({ row: { id }, targetIndex }: GridRowOrderChangeParams) => { + await client.mutate({ + mutation: updateEntityPositionMutation, + variables: { id, input: { position: targetIndex + 1 } }, + awaitRefetchQueries: true, + refetchQueries: [entitiesQuery], + }); + }; + + const columns: GridColDef[] = useMemo( + () => [ + { + field: "title", + headerName: intl.formatMessage({ id: "entity.title", defaultMessage: "Title" }), + filterable: false, + sortable: false, + flex: 1, + minWidth: 150, + }, + // ... all columns must have filterable: false and sortable: false + ], + [intl, client], + ); + + // Query uses fixed sort by position, no filter/search, loads all rows + const { data, loading, error } = useQuery(entitiesQuery, { + variables: { + sort: { field: "position", direction: "ASC" }, + offset: 0, + limit: 100, + }, + }); + + const rowCount = useBufferedRowCount(data?.entities.totalCount); + if (error) throw error; + + // Map rows to include __reorder__ field for drag preview + const rows = + data?.entities.nodes.map((node) => ({ + ...node, + __reorder__: node.title, + })) ?? []; + + return ( + + ); +} +``` + +## Toolbar + +Row reordering grids have no search or filter — only the add button: + +```tsx +function EntitiesGridToolbar() { + return ( + + + + + ); +} +``` + +## Rules + +- All columns must have `filterable: false` and `sortable: false` +- Query uses fixed `sort: { field: "position", direction: "ASC" }`, `offset: 0`, `limit: 100` +- Map rows to include `__reorder__` field (display text during drag, typically `title` or `name`) +- Add `rowReordering` and `hideFooterPagination` props to DataGridPro +- Toolbar omits `` and `` +- The position mutation uses the entity's update mutation with `{ position: targetIndex + 1 }` diff --git a/package-skills/comet-admin-datagrid/references/grid-03-sub-entity.md b/package-skills/comet-admin-datagrid/references/grid-03-sub-entity.md new file mode 100644 index 0000000000..a366bfbc46 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-03-sub-entity.md @@ -0,0 +1,56 @@ +# Grid Variant: Sub-Entity (with Parent ID) + +Used when the grid shows entities scoped to a parent (e.g. variants of a product). The parent ID is passed as a required prop and forwarded to the query. + +## Template + +```tsx +type ProductVariantsGridProps = { + product: string; // parent entity ID +}; + +export function ProductVariantsGrid({ product }: ProductVariantsGridProps) { + const client = useApolloClient(); + const intl = useIntl(); + const dataGridProps = { + ...useDataGridRemote({ + queryParamsPrefix: "product-variants", + }), + ...usePersistentColumnState("ProductVariantsGrid"), + }; + + // ... columns + + const { filter: gqlFilter, search: gqlSearch } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const { data, loading, error } = useQuery(productVariantsQuery, { + variables: { + product, // forwarded parent ID + filter: gqlFilter, + search: gqlSearch, + sort: muiGridSortToGql(dataGridProps.sortModel, columns) ?? [], + offset: dataGridProps.paginationModel.page * dataGridProps.paginationModel.pageSize, + limit: dataGridProps.paginationModel.pageSize, + }, + }); + // ... +} +``` + +## GQL + +```graphql +query ProductVariantsGrid($product: ID!, $offset: Int!, $limit: Int!, $sort: [ProductVariantSort!], $search: String, $filter: ProductVariantFilter) { + productVariants(product: $product, offset: $offset, limit: $limit, sort: $sort, search: $search, filter: $filter) { + nodes { + ...ProductVariantsGridItem + } + totalCount + } +} +``` + +## Rules + +- The parent ID argument (e.g. `$product: ID!`) is a required prop on the grid component +- Pass it to both the query variables and to `useDataGridExcelExport` variables (if excel export is used) +- Otherwise the grid follows the standard paginated pattern diff --git a/package-skills/comet-admin-datagrid/references/grid-04-select.md b/package-skills/comet-admin-datagrid/references/grid-04-select.md new file mode 100644 index 0000000000..11970b738d --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-04-select.md @@ -0,0 +1,68 @@ +# Grid Variant: Select / Checkbox + +Used for picker dialogs where users select one or multiple entities. + +## Multi-Select Template + +```tsx +type SelectEntitiesGridProps = { + rowSelectionModel?: DataGridProProps["rowSelectionModel"]; + onRowSelectionModelChange?: DataGridProProps["onRowSelectionModelChange"]; +}; + +export function SelectEntitiesGrid({ rowSelectionModel, onRowSelectionModelChange }: SelectEntitiesGridProps) { + const intl = useIntl(); + const dataGridProps = { + ...useDataGridRemote(), + ...usePersistentColumnState("SelectEntitiesGrid"), + rowSelectionModel, + onRowSelectionModelChange, + checkboxSelection: true, + keepNonExistentRowsSelected: true, + }; + + const columns: GridColDef[] = useMemo( + () => [ + // data columns only, no actions column + ], + [intl], + ); + + // ... standard query wiring + + return ( + + ); +} +``` + +## Toolbar + +Toolbar has only search and filter — no add button: + +```tsx +function SelectEntitiesGridToolbar() { + return ( + + + + + + ); +} +``` + +## Rules + +- No actions column, no delete mutation, no edit icon button +- No `onRowClick` — selection is via checkboxes +- `keepNonExistentRowsSelected: true` retains selections across pages +- No `useStackSwitchApi` or `useApolloClient` needed +- Toolbar has only search/filter, no add button diff --git a/package-skills/comet-admin-datagrid/references/grid-05-excel-export.md b/package-skills/comet-admin-datagrid/references/grid-05-excel-export.md new file mode 100644 index 0000000000..0e38e4b2b5 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-05-excel-export.md @@ -0,0 +1,96 @@ +# Grid Feature: Excel Export + +Adds a "Download as Excel" action to the grid toolbar using `useDataGridExcelExport` and `CrudMoreActionsMenu`. + +## Toolbar with Export + +The toolbar receives an `exportApi` prop and renders the export action inside a `CrudMoreActionsMenu`. + +```tsx +import { CrudMoreActionsMenu, DataGridToolbar, ExportApi, FillSpace, GridFilterButton, messages } from "@comet/admin"; +import { Excel as ExcelIcon } from "@comet/admin-icons"; +import { CircularProgress } from "@mui/material"; +import { GridToolbarProps, GridToolbarQuickFilter } from "@mui/x-data-grid-pro"; +import { FormattedMessage } from "react-intl"; + +interface EntitiesGridToolbarProps extends GridToolbarProps { + toolbarAction?: ReactNode; + exportApi: ExportApi; +} + +function EntitiesGridToolbar({ toolbarAction, exportApi }: EntitiesGridToolbarProps) { + return ( + + + + + , + icon: exportApi.loading ? : , + onClick: () => exportApi.exportGrid(), + disabled: exportApi.loading, + }, + ]} + /> + {toolbarAction} + + ); +} +``` + +## Grid Component with Export Hook + +```tsx +const exportApi = useDataGridExcelExport< + GQLEntitiesGridQuery["entities"]["nodes"][0], + GQLEntitiesGridQuery, + Omit +>({ + columns, + variables: { + ...muiGridFilterToGql(columns, dataGridProps.filterModel), + // include any extra required variables (e.g. parent ID for sub-entity grids) + }, + query: entitiesQuery, + resolveQueryNodes: (data) => data.entities.nodes, + totalCount: data?.entities.totalCount ?? 0, + exportOptions: { + fileName: "Entities", + }, +}); + +return ( + +); +``` + +## Rules + +- Import `ExportApi`, `messages`, `useDataGridExcelExport`, `CrudMoreActionsMenu` from `@comet/admin` +- Import `Excel as ExcelIcon` from `@comet/admin-icons` +- The toolbar interface extends `GridToolbarProps` and adds `exportApi: ExportApi` +- Pass the toolbar props via `slotProps.toolbar` with a type assertion +- The `Omit<..., "offset" | "limit">` generic removes pagination vars from the export query +- For sub-entity grids, include the parent ID in the `variables` object +- Columns with `disableExport: true` are excluded from the export (e.g. actions column, overview columns) +- Use `messages.downloadAsExcel` from `@comet/admin` for the i18n label +- Show `CircularProgress` spinner while export is loading diff --git a/package-skills/comet-admin-datagrid/references/grid-06-responsive-columns.md b/package-skills/comet-admin-datagrid/references/grid-06-responsive-columns.md new file mode 100644 index 0000000000..49cb613c93 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-06-responsive-columns.md @@ -0,0 +1,71 @@ +# Grid Feature: Responsive Columns + +Use `visible` with `theme.breakpoints` to show/hide columns based on screen size. Pair an overview column (for small screens) with individual columns (for larger screens). + +## Template + +```tsx +const theme = useTheme(); + +const columns: GridColDef[] = useMemo( + () => [ + { + field: "overview", + headerName: intl.formatMessage({ id: "entity.overview", defaultMessage: "Overview" }), + filterable: false, + renderCell: ({ row }) => ( + : "-", + type: row.type ?? "-", + }} + /> + } + /> + ), + flex: 1, + visible: theme.breakpoints.down("md"), + disableExport: true, + sortBy: ["title", "price", "type"], + minWidth: 200, + }, + { + field: "title", + headerName: intl.formatMessage({ id: "entity.title", defaultMessage: "Title" }), + flex: 1, + visible: theme.breakpoints.up("md"), + minWidth: 200, + }, + { + field: "price", + headerName: intl.formatMessage({ id: "entity.price", defaultMessage: "Price" }), + type: "number", + // ... + visible: theme.breakpoints.up("md"), + minWidth: 150, + }, + ], + [intl, theme], +); +``` + +## Visibility Options + +- `theme.breakpoints.up("md")` — visible on md and larger +- `theme.breakpoints.down("md")` — visible below md +- `theme.breakpoints.only("sm")` — visible only on sm +- `theme.breakpoints.between("sm", "md")` — visible between sm and md +- `visible: false` — always hidden (e.g. audit timestamps) + +## Overview Column Rules + +- `filterable: false` — overview columns cannot be filtered +- `disableExport: true` — exclude from excel export (individual columns export instead) +- `sortBy: [...]` — array of actual field names this virtual column can sort by +- Uses `GridCellContent` for rich rendering with `primaryText` and `secondaryText` +- Requires `useTheme()` import from `@mui/material` and `theme` in `useMemo` deps diff --git a/package-skills/comet-admin-datagrid/references/grid-07-initial-sort-filter.md b/package-skills/comet-admin-datagrid/references/grid-07-initial-sort-filter.md new file mode 100644 index 0000000000..e256c63120 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-07-initial-sort-filter.md @@ -0,0 +1,39 @@ +# Grid Feature: Initial Sort & Filter + +Set default sort order and/or pre-applied filters when the grid first loads via `useDataGridRemote` options. + +## Initial Sort + +```tsx +const dataGridProps = { + ...useDataGridRemote({ + initialSort: [ + { field: "inStock", sort: "desc" }, + { field: "price", sort: "asc" }, + ], + queryParamsPrefix: "products", + }), + ...usePersistentColumnState("ProductsGrid"), +}; +``` + +## Initial Filter + +```tsx +const dataGridProps = { + ...useDataGridRemote({ + initialFilter: { + items: [{ field: "type", operator: "is", value: "shirt" }], + }, + queryParamsPrefix: "products", + }), + ...usePersistentColumnState("ProductsGrid"), +}; +``` + +## Rules + +- `initialSort` accepts an array of `{ field: string, sort: "asc" | "desc" }` — supports multi-column sort +- `initialFilter.items` follows the MUI GridFilterItem shape: `{ field, operator, value }` +- Both are optional and can be combined +- These only set the initial state — users can change sort/filter interactively diff --git a/package-skills/comet-admin-datagrid/references/grid-08-external-filter-prop.md b/package-skills/comet-admin-datagrid/references/grid-08-external-filter-prop.md new file mode 100644 index 0000000000..ddcc48294f --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-08-external-filter-prop.md @@ -0,0 +1,51 @@ +# Grid Feature: External Filter Prop + +Grids can accept an external `filter` prop to scope results by parent context (e.g. filter by category). The external filter is combined with the grid's own filter using `{ and: [...] }`. + +## Template + +```tsx +type EntitiesGridProps = { + filter?: GQLEntityFilter; + toolbarAction?: ReactNode; + rowAction?: (params: GridRenderCellParams) => ReactNode; + actionsColumnWidth?: number; + onRowClick?: DataGridProProps["onRowClick"]; +}; + +export function EntitiesGrid({ filter, toolbarAction, rowAction, actionsColumnWidth = 52, onRowClick }: EntitiesGridProps) { + // ... standard setup + + const { filter: gqlFilter, search: gqlSearch } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const { data, loading, error } = useQuery(entitiesQuery, { + variables: { + // Combine external filter with grid filter + filter: filter ? { and: [gqlFilter, filter] } : gqlFilter, + search: gqlSearch, + sort: muiGridSortToGql(dataGridProps.sortModel, columns) ?? [], + offset: dataGridProps.paginationModel.page * dataGridProps.paginationModel.pageSize, + limit: dataGridProps.paginationModel.pageSize, + }, + }); + + // ... render with toolbarAction and rowAction +} +``` + +## Common Props + +| Prop | Type | Purpose | +| -------------------- | -------------------------------- | ---------------------------------------------------------------------------- | +| `filter` | `GQLEntityFilter` | External GQL filter combined with grid filter via `{ and: [...] }` | +| `toolbarAction` | `ReactNode` | Extra element rendered in the toolbar (passed via `slotProps`) | +| `rowAction` | `(params) => ReactNode` | Custom action rendered in the actions column alongside edit/delete | +| `actionsColumnWidth` | `number` | Width of actions column (default 52 for single action, 84 for edit + delete) | +| `onRowClick` | `DataGridProProps["onRowClick"]` | Override row click behavior (alternative to `useStackSwitchApi`) | + +## Rules + +- Import the filter type from `@src/graphql.generated` (e.g. `GQLProductFilter`) +- When `filter` prop is provided, wrap with `{ and: [gqlFilter, filter] }` +- When `filter` prop is not provided, use `gqlFilter` directly +- `toolbarAction` is passed to toolbar via `slotProps.toolbar` +- `rowAction` is rendered inside the actions column `renderCell` diff --git a/package-skills/comet-admin-datagrid/references/grid-09-content-scope.md b/package-skills/comet-admin-datagrid/references/grid-09-content-scope.md new file mode 100644 index 0000000000..62cffb69f2 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-09-content-scope.md @@ -0,0 +1,48 @@ +# Grid Feature: Content Scope + +Used when the entity's GraphQL query requires a `scope` argument (e.g. `scope: ScopeInput!`). Most Comet DXP entities are scoped. + +## Template + +```tsx +import { useContentScope } from "@comet/cms-admin"; + +export function EntitiesGrid() { + const { scope } = useContentScope(); + // ... standard setup + + const { filter: gqlFilter, search: gqlSearch } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const { data, loading, error } = useQuery(entitiesQuery, { + variables: { + scope, // pass content scope to query + filter: gqlFilter, + search: gqlSearch, + sort: muiGridSortToGql(dataGridProps.sortModel, columns) ?? [], + offset: dataGridProps.paginationModel.page * dataGridProps.paginationModel.pageSize, + limit: dataGridProps.paginationModel.pageSize, + }, + }); + // ... +} +``` + +## GQL + +```graphql +query EntitiesGrid($scope: EntityScopeInput!, $offset: Int!, $limit: Int!, $sort: [EntitySort!]!, $search: String, $filter: EntityFilter) { + entities(scope: $scope, offset: $offset, limit: $limit, sort: $sort, search: $search, filter: $filter) { + nodes { + ...EntitiesGridItem + } + totalCount + } +} +``` + +## Rules + +- Check the GraphQL schema for a `scope` argument on the entity's list query — if present, the grid must pass it +- Import `useContentScope` from `@comet/cms-admin` +- Pass `scope` to the query variables alongside filter/search/sort/pagination +- Also pass `scope` to `useDataGridExcelExport` variables if excel export is used +- The scope variable type (e.g. `ScopeInput!`) must match the schema exactly diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-01-string.md b/package-skills/comet-admin-datagrid/references/grid-col-def-01-string.md new file mode 100644 index 0000000000..b334c7ea3f --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-01-string.md @@ -0,0 +1,27 @@ +# Column: String + +## Primary label field + +```tsx +{ + field: "title", + headerName: intl.formatMessage({ id: "product.title", defaultMessage: "Title" }), + flex: 1, + minWidth: 200, +} +``` + +## Secondary field + +```tsx +{ + field: "slug", + headerName: intl.formatMessage({ id: "product.slug", defaultMessage: "Slug" }), + width: 150, +} +``` + +## Rules + +- Use `flex: 1` + `minWidth` for the primary label column so it fills available space +- Use fixed `width` for secondary string fields diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-02-boolean.md b/package-skills/comet-admin-datagrid/references/grid-col-def-02-boolean.md new file mode 100644 index 0000000000..9c7344c589 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-02-boolean.md @@ -0,0 +1,10 @@ +# Column: Boolean + +```tsx +{ + field: "inStock", + headerName: intl.formatMessage({ id: "product.inStock", defaultMessage: "In Stock" }), + type: "boolean", + width: 100, +} +``` diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-03-number.md b/package-skills/comet-admin-datagrid/references/grid-col-def-03-number.md new file mode 100644 index 0000000000..82dbf3c935 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-03-number.md @@ -0,0 +1,43 @@ +# Column: Number + +## Plain (Int / Float) + +```tsx +{ + field: "soldCount", + headerName: intl.formatMessage({ id: "product.soldCount", defaultMessage: "Sold Count" }), + type: "number", + width: 120, +} +``` + +## Currency (assume EUR unless unclear, then ask user) + +```tsx +{ + field: "price", + renderHeader: () => ( + <> + + }> + + + + ), + headerName: intl.formatMessage({ id: "product.price", defaultMessage: "Price" }), + type: "number", + renderCell: ({ value }) => { + return typeof value === "number" ? ( + + ) : ( + "" + ); + }, + flex: 1, +} +``` + +## Rules + +- Currency columns use `renderHeader` with a tooltip indicating the currency +- Use `FormattedNumber` from `react-intl` for currency formatting diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-04-datetime.md b/package-skills/comet-admin-datagrid/references/grid-col-def-04-datetime.md new file mode 100644 index 0000000000..984b6a4b7b --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-04-datetime.md @@ -0,0 +1,29 @@ +# Column: DateTime / LocalDate + +## DateTime + +```tsx +{ + ...dataGridDateTimeColumn, + field: "createdAt", + headerName: intl.formatMessage({ id: "product.createdAt", defaultMessage: "Created At" }), + width: 170, + visible: false, // audit timestamps are hidden by default +} +``` + +## LocalDate + +```tsx +{ + ...dataGridDateColumn, + field: "availableSince", + headerName: intl.formatMessage({ id: "product.availableSince", defaultMessage: "Available Since" }), + width: 140, +} +``` + +## Rules + +- Spread `dataGridDateTimeColumn` or `dataGridDateColumn` from `@comet/admin` as the base +- Audit timestamps (`createdAt`, `updatedAt`) should use `visible: false` by default diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-05-enum.md b/package-skills/comet-admin-datagrid/references/grid-col-def-05-enum.md new file mode 100644 index 0000000000..9ba9c11778 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-05-enum.md @@ -0,0 +1,120 @@ +# Column: Enum + +**Default: Always render enum columns as chips with filterable select** unless the user explicitly requests a different rendering (e.g. static select, plain text). + +## With Chip Component + Filter (default) + +Enum columns should always be filterable via a select dropdown. Use `type: "singleSelect"` with `valueOptions` derived from the translatable enum's `messageDescriptorMap` using the `messageDescriptorMapToValueOptions` helper, and render a chip component in the cell. + +```tsx +import { messageDescriptorMapToValueOptions } from "@src/common/components/enums/messageDescriptorMapToValueOptions/messageDescriptorMapToValueOptions"; +import { productStatusMessageDescriptorMap } from "./components/productStatus/ProductStatus"; +import { ProductStatusChip } from "./components/productStatusChip/ProductStatusChip"; + +{ + field: "status", + headerName: intl.formatMessage({ id: "product.status", defaultMessage: "Status" }), + type: "singleSelect", + valueOptions: messageDescriptorMapToValueOptions(productStatusMessageDescriptorMap, intl), + width: 140, + renderCell: ({ row }) => , +} +``` + +### `messageDescriptorMapToValueOptions` helper + +Located at `@src/common/components/enums/messageDescriptorMapToValueOptions/messageDescriptorMapToValueOptions.ts`. + +Converts a translatable enum's `messageDescriptorMap` (from `createTranslatableEnum`) into `{ value, label }[]` with string labels suitable for DataGrid `valueOptions`. Takes `(map, intl)` as arguments. + +If this helper does not exist in the project yet, create it: + +```ts +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), + })); +} +``` + +## With Editable Chip (inline status change) + +Use an editable chip component when the user wants to change the enum value directly in the grid without opening the edit form. The editable chip handles its own Apollo mutation. Wrap it in a `Box` with `stopPropagation` to prevent row click from triggering navigation. + +```tsx +import { Box } from "@mui/material"; +import { CuisineChipEditableForRecipe } from "./components/cuisineChipEditableForRecipe/CuisineChipEditableForRecipe"; +import { cuisineMessageDescriptorMap } from "./components/cuisine/Cuisine"; + +{ + field: "cuisine", + headerName: intl.formatMessage({ id: "recipe.cuisine", defaultMessage: "Cuisine" }), + type: "singleSelect", + valueOptions: messageDescriptorMapToValueOptions(cuisineMessageDescriptorMap, intl), + width: 160, + renderCell: ({ row }) => ( + e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> + + + ), +} +``` + +- The `Box` wrapper with `stopPropagation` on both `onClick` and `onMouseDown` is **required** to prevent the chip click from triggering `onRowClick` navigation +- Use the `comet-admin-translatable-enum` skill to create the editable chip component if it doesn't exist +- Keep `type: "singleSelect"` and `valueOptions` so the column remains filterable + +## With Static Select (only when user explicitly requests it) + +Use `renderStaticSelectCell` only when the user specifically asks for inline labels instead of chips: + +```tsx +{ + field: "type", + headerName: intl.formatMessage({ id: "product.type", defaultMessage: "Type" }), + type: "singleSelect", + valueFormatter: (value, row) => row.type?.toString(), + valueOptions: [ + { + value: "Cap", + label: intl.formatMessage({ id: "product.type.cap", defaultMessage: "Cap" }), + cellContent: ( + } + icon={} + /> + ), + }, + { + value: "Shirt", + label: intl.formatMessage({ id: "product.type.shirt", defaultMessage: "Shirt" }), + }, + { + value: "Tie", + label: intl.formatMessage({ id: "product.type.tie", defaultMessage: "Tie" }), + }, + ], + renderCell: renderStaticSelectCell, + flex: 1, + minWidth: 150, + maxWidth: 150, +} +``` + +## Rules + +- **Enum columns always render as chips with a filterable select by default.** For every enum field, create a chip component using the `comet-admin-translatable-enum` skill and use it in the grid. Only fall back to `renderStaticSelectCell` when the user explicitly requests it. +- **Always add `type: "singleSelect"` and `valueOptions`** to enum columns so they are filterable via a select dropdown. +- **Use `messageDescriptorMapToValueOptions`** to convert the translatable enum's `messageDescriptorMap` to `valueOptions`. Import the `messageDescriptorMap` from the translatable enum file (e.g. `productStatusMessageDescriptorMap`). If the helper does not exist in the project, create it first. +- Search for an existing chip component (glob `**/Chip.tsx`) before generating a new one. +- If no chip component exists, use the `comet-admin-translatable-enum` skill to create one first. +- `valueOptions` can include `cellContent` with `GridCellContent` for rich rendering (icon, secondary text) when using `renderStaticSelectCell`. +- `valueFormatter` is only needed when using `renderStaticSelectCell`: `(value, row) => row.type?.toString()`. diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-06-many-to-one.md b/package-skills/comet-admin-datagrid/references/grid-col-def-06-many-to-one.md new file mode 100644 index 0000000000..c08507a7b7 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-06-many-to-one.md @@ -0,0 +1,18 @@ +# Column: ManyToOne Relation + +```tsx +{ + field: "category", + headerName: intl.formatMessage({ id: "product.category", defaultMessage: "Category" }), + flex: 1, + minWidth: 100, + valueGetter: (_value, row) => row.category?.title, + filterOperators: ProductCategoryFilterOperators, +} +``` + +## Rules + +- Use `valueGetter` to extract the label field from the nested relation object +- Requires a custom filter component — see [grid-col-def-13-relation-filter.md](grid-col-def-13-relation-filter.md) for the `FilterOperators` pattern +- GQL fragment must include the nested selection: `category { id title }` diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-07-many-to-many.md b/package-skills/comet-admin-datagrid/references/grid-col-def-07-many-to-many.md new file mode 100644 index 0000000000..f42483b59c --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-07-many-to-many.md @@ -0,0 +1,21 @@ +# Column: ManyToMany / Array of Relations + +```tsx +{ + ...dataGridManyToManyColumn, + field: "tags", + headerName: intl.formatMessage({ id: "product.tags", defaultMessage: "Tags" }), + sortable: false, + renderCell: ({ row }) => <>{row.tags.map((tag) => tag.title).join(", ")}, + flex: 1, + disableExport: true, + minWidth: 150, +} +``` + +## Rules + +- Spread `dataGridManyToManyColumn` from `@comet/admin` as the base +- Include the label field (e.g. `title`) in the GQL fragment for the related entity +- Use `disableExport: true` for array columns +- Use `sortable: false` — many-to-many cannot be sorted server-side diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-08-one-to-many.md b/package-skills/comet-admin-datagrid/references/grid-col-def-08-one-to-many.md new file mode 100644 index 0000000000..c62abcb1ed --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-08-one-to-many.md @@ -0,0 +1,21 @@ +# Column: OneToMany + +```tsx +{ + ...dataGridOneToManyColumn, + field: "variants", + headerName: intl.formatMessage({ id: "product.variants", defaultMessage: "Variants" }), + sortable: false, + renderCell: ({ row }) => <>{row.variants.map((variant) => variant.name).join(", ")}, + flex: 1, + disableExport: true, + minWidth: 150, +} +``` + +## Rules + +- Spread `dataGridOneToManyColumn` from `@comet/admin` as the base +- Include the label field (e.g. `name`) in the GQL fragment for the related entity +- Use `disableExport: true` for array columns +- Use `sortable: false` — one-to-many cannot be sorted server-side diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-09-array-of-scalars.md b/package-skills/comet-admin-datagrid/references/grid-col-def-09-array-of-scalars.md new file mode 100644 index 0000000000..7309b0024d --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-09-array-of-scalars.md @@ -0,0 +1,16 @@ +# Column: Array of Scalars + +```tsx +{ + field: "articleNumbers", + headerName: intl.formatMessage({ id: "product.articleNumbers", defaultMessage: "Article Numbers" }), + width: 200, + sortable: false, + valueGetter: (value: string[]) => value?.join(", "), +} +``` + +## Rules + +- Use `sortable: false` — arrays cannot be sorted server-side +- Use `valueGetter` to join array values into a comma-separated string diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-10-nested-object.md b/package-skills/comet-admin-datagrid/references/grid-col-def-10-nested-object.md new file mode 100644 index 0000000000..99ba21b3b1 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-10-nested-object.md @@ -0,0 +1,36 @@ +# Column: Nested Scalar Object + +```tsx +{ + field: "address_street", + headerName: intl.formatMessage({ id: "manufacturer.address.street", defaultMessage: "Address Street" }), + filterable: false, + sortable: false, + valueGetter: (_value, row) => row.address?.street, + flex: 1, + minWidth: 150, +} +``` + +## Deeply Nested + +Use underscore-separated field names for deeply nested objects: + +```tsx +{ + field: "address_alternativeAddress_street", + headerName: intl.formatMessage({ id: "manufacturer.address.alternativeAddress.street", defaultMessage: "Alt-Address Street" }), + filterable: false, + sortable: false, + valueGetter: (_value, row) => row.address?.alternativeAddress?.street, + flex: 1, + minWidth: 150, +} +``` + +## Rules + +- Use `sortable: false` and `filterable: false` — nested objects cannot be sorted/filtered server-side +- Field name uses underscore notation: `parentField_nestedField` +- Use `valueGetter` with optional chaining to extract the nested value +- Formatting is case-by-case — ask the user how to display the nested fields diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-11-file-upload.md b/package-skills/comet-admin-datagrid/references/grid-col-def-11-file-upload.md new file mode 100644 index 0000000000..98cc2bd0e8 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-11-file-upload.md @@ -0,0 +1,86 @@ +# Column: FileUpload + +## Image (single FileUpload known to be an image) + +**Important:** FileUpload `imageUrl` returns a **relative** path (e.g. `/file-uploads/hash/id/timeout/resizeWidth/filename`). You must prepend the API URL using `useCometConfig()` from `@comet/cms-admin`. + +```tsx +import { useCometConfig } from "@comet/cms-admin"; + +const { apiUrl } = useCometConfig(); + +{ + field: "preview", + headerName: intl.formatMessage({ id: "product.preview", defaultMessage: "Preview" }), + sortable: false, + filterable: false, + width: 80, + renderCell: ({ row }) => { + if (!row.preview?.imageUrl) return null; + return ( + + + + ); + }, +} +``` + +GQL fragment: `preview { id imageUrl(resizeWidth: 80) }` + +Add `apiUrl` to the `useMemo` dependency array for columns. + +## Document / array of files + +```tsx +{ + field: "datasheets", + headerName: intl.formatMessage({ id: "product.datasheets", defaultMessage: "Datasheets" }), + width: 160, + sortable: false, + valueGetter: (value) => value?.map((f: { name: string }) => f.name).join(", "), +} +``` + +GQL fragment: `datasheets { name }` + +## DAM Image (`DamImageBlockData` / block field) + +DAM Image block fields store their data as a JSON block. To render a preview in the grid, access the first attached block's `damFile.fileUrl`. Unlike FileUpload, DAM file URLs are **absolute** and do not need the `apiUrl` prefix. + +```tsx +{ + field: "images", + headerName: intl.formatMessage({ id: "product.images", defaultMessage: "Images" }), + sortable: false, + filterable: false, + width: 80, + renderCell: ({ row }) => { + const damFile = row.images?.attachedBlocks?.[0]?.props?.damFile; + if (!damFile?.fileUrl) return null; + return ( + + + + ); + }, +} +``` + +GQL fragment: Include the block field name directly (e.g. `images`) — the block JSON is returned as-is. + +## Rules + +- Ask the user whether a FileUpload field is an image or a document to pick the right variant +- Use `sortable: false` and `filterable: false` for all file upload and image columns +- **FileUpload `imageUrl` is relative** — always prepend `apiUrl` from `useCometConfig()` +- **DAM Image `fileUrl` is absolute** — use directly without prefix +- Add `apiUrl` to the columns `useMemo` dependency array when using FileUpload image columns diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-12-id.md b/package-skills/comet-admin-datagrid/references/grid-col-def-12-id.md new file mode 100644 index 0000000000..cb7598cc02 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-12-id.md @@ -0,0 +1,16 @@ +# Column: ID + +```tsx +{ + ...dataGridIdColumn, + field: "id", + headerName: intl.formatMessage({ id: "entity.id", defaultMessage: "ID" }), + flex: 1, + minWidth: 150, +} +``` + +## Rules + +- Spread `dataGridIdColumn` from `@comet/admin` as the base +- Only include if the user explicitly wants to show the ID column (not common) diff --git a/package-skills/comet-admin-datagrid/references/grid-col-def-13-relation-filter.md b/package-skills/comet-admin-datagrid/references/grid-col-def-13-relation-filter.md new file mode 100644 index 0000000000..88d19bf1e3 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-col-def-13-relation-filter.md @@ -0,0 +1,137 @@ +# Relation Filter Patterns + +ManyToOne relation columns need a custom filter component that provides an Autocomplete dropdown backed by a server-side search query. The filter exports a `FilterOperators` array that gets passed to the column's `filterOperators` prop. + +## GQL Query + +```ts +import { gql } from "@apollo/client"; + +export const FilterQuery = gql` + query Filter($offset: Int!, $limit: Int!, $search: String) { + s(offset: $offset, limit: $limit, search: $search) { + nodes { + id + title + } + } + } +`; +``` + +- Only fetch `id` and the label field (e.g. `title`, `name`) +- Match the exact query name from the schema + +--- + +## Filter Component + +```tsx +import { gql, useQuery } from "@apollo/client"; +import { ClearInputAdornment } from "@comet/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 { type GQLFilterQuery, type GQLFilterQueryVariables } from "./Filter.generated"; +import { FilterQuery } from "./Filter.gql"; + +function Filter({ item, applyValue, apiRef }: GridFilterInputValueProps) { + const intl = useIntl(); + const [search, setSearch] = useState(undefined); + const [debouncedSearch] = useDebounce(search, 500); + const rootProps = useGridRootProps(); + + const { data } = useQueryFilterQuery, GQLFilterQueryVariables>(FilterQuery, { + variables: { + offset: 0, + limit: 10, + search: debouncedSearch, + }, + }); + + const handleApplyValue = useCallback( + (value: string | undefined) => { + applyValue({ + ...item, + id: item.id, + operator: "equals", + value, + }); + }, + [applyValue, item], + ); + + return ( + s.nodes ?? []} + autoHighlight + value={item.value ? item.value : null} + filterOptions={(x) => x} + disableClearable + isOptionEqualToValue={(option, value) => option.id == value} + getOptionLabel={(option) => { + return ( + option.title ?? + data?.s.nodes.find((item) => item.id === option)?.title ?? + option + ); + }} + onChange={(event, value, reason) => { + handleApplyValue(value ? value.id : undefined); + }} + renderInput={(params) => ( + .placeholder", + defaultMessage: "Choose a ", + })} + value={search ? search : null} + onChange={(event) => { + setSearch(event.target.value); + }} + label={apiRef.current.getLocaleText("filterPanelInputLabel")} + slotProps={{ + inputLabel: { shrink: true }, + input: { + ...params.InputProps, + endAdornment: ( + <> + handleApplyValue(undefined)} + /> + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + /> + ); +} + +export const FilterOperators: GridFilterOperator[] = [ + { + value: "equals", + getApplyFilterFn: (filterItem) => { + throw new Error("not implemented, we filter server side"); + }, + InputComponent: Filter, + }, +]; +``` + +## Rules + +- Requires `use-debounce` package — check `admin/package.json` and run `npm --prefix admin install use-debounce` if not installed +- The label field in `getOptionLabel` (e.g. `title`) must match what's fetched in the GQL query +- Ask the user for the label field if it's not obvious from the schema (e.g. `name` vs `title`) +- `Filter.generated.ts` is auto-generated by codegen — do not create it manually diff --git a/package-skills/comet-admin-datagrid/references/grid-toolbar-00-standard.md b/package-skills/comet-admin-datagrid/references/grid-toolbar-00-standard.md new file mode 100644 index 0000000000..50545671fa --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-toolbar-00-standard.md @@ -0,0 +1,35 @@ +# Toolbar: Standard + +The default grid toolbar with quick search, column filtering, and an add button. Passed to the DataGrid via `slots.toolbar`. + +## Template + +```tsx +import { Button, DataGridToolbar, FillSpace, GridFilterButton, StackLink } from "@comet/admin"; +import { GridToolbarQuickFilter } from "@mui/x-data-grid-pro"; +import { FormattedMessage } from "react-intl"; +import { Add as AddIcon } from "@comet/admin-icons"; + +export function sGridToolbar() { + return ( + + + + + + + ); +} +``` + +## Rules + +- Always include `` — drives the `search` GQL variable via `muiGridFilterToGql` +- Always include `` — opens the column filter panel +- Always include `` between filter controls and action buttons +- Include the "New X" ` + + ); +} +``` + +## Rules + +- No `` — search is disabled for row reordering grids +- No `` — filtering is disabled for row reordering grids +- Only `` and the add button diff --git a/package-skills/comet-admin-datagrid/references/grid-toolbar-03-select.md b/package-skills/comet-admin-datagrid/references/grid-toolbar-03-select.md new file mode 100644 index 0000000000..be13e405e3 --- /dev/null +++ b/package-skills/comet-admin-datagrid/references/grid-toolbar-03-select.md @@ -0,0 +1,26 @@ +# Toolbar: Select / Picker + +Select grids have search and filter but no add button — the toolbar is for finding entities to select. + +## Template + +```tsx +import { DataGridToolbar, FillSpace, GridFilterButton } from "@comet/admin"; +import { GridToolbarQuickFilter } from "@mui/x-data-grid-pro"; + +function SelectsGridToolbar() { + return ( + + + + + + ); +} +``` + +## Rules + +- No add button — selection grids are read-only pickers +- Keep `` and `` for search/filter +- `` at the end keeps layout consistent From 3cd676a59b79c343d6686d980ecd3a3f6d8d55b6 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 13:38:31 +0100 Subject: [PATCH 04/12] add comet-admin-form-skill --- package-skills/comet-admin-form/SKILL.md | 234 ++++++++++++++ .../references/form-00-shell.md | 287 ++++++++++++++++++ .../references/form-field-01-string.md | 18 ++ .../references/form-field-02-text.md | 19 ++ .../references/form-field-03-number.md | 48 +++ .../references/form-field-04-boolean.md | 13 + .../references/form-field-05-datetime.md | 56 ++++ .../references/form-field-06-enum.md | 28 ++ .../references/form-field-07-many-to-one.md | 225 ++++++++++++++ .../references/form-field-08-many-to-many.md | 45 +++ .../references/form-field-09-nested-object.md | 17 ++ .../references/form-field-10-file-upload.md | 56 ++++ .../references/form-field-11-block.md | 73 +++++ .../references/form-field-12-switch.md | 18 ++ .../references/form-field-13-async-select.md | 95 ++++++ .../references/form-layout-01-fieldset.md | 24 ++ .../form-pattern-01-conditional-fields.md | 206 +++++++++++++ .../references/form-pattern-02-adornments.md | 64 ++++ .../references/form-pattern-03-validation.md | 167 ++++++++++ 19 files changed, 1693 insertions(+) create mode 100644 package-skills/comet-admin-form/SKILL.md create mode 100644 package-skills/comet-admin-form/references/form-00-shell.md create mode 100644 package-skills/comet-admin-form/references/form-field-01-string.md create mode 100644 package-skills/comet-admin-form/references/form-field-02-text.md create mode 100644 package-skills/comet-admin-form/references/form-field-03-number.md create mode 100644 package-skills/comet-admin-form/references/form-field-04-boolean.md create mode 100644 package-skills/comet-admin-form/references/form-field-05-datetime.md create mode 100644 package-skills/comet-admin-form/references/form-field-06-enum.md create mode 100644 package-skills/comet-admin-form/references/form-field-07-many-to-one.md create mode 100644 package-skills/comet-admin-form/references/form-field-08-many-to-many.md create mode 100644 package-skills/comet-admin-form/references/form-field-09-nested-object.md create mode 100644 package-skills/comet-admin-form/references/form-field-10-file-upload.md create mode 100644 package-skills/comet-admin-form/references/form-field-11-block.md create mode 100644 package-skills/comet-admin-form/references/form-field-12-switch.md create mode 100644 package-skills/comet-admin-form/references/form-field-13-async-select.md create mode 100644 package-skills/comet-admin-form/references/form-layout-01-fieldset.md create mode 100644 package-skills/comet-admin-form/references/form-pattern-01-conditional-fields.md create mode 100644 package-skills/comet-admin-form/references/form-pattern-02-adornments.md create mode 100644 package-skills/comet-admin-form/references/form-pattern-03-validation.md diff --git a/package-skills/comet-admin-form/SKILL.md b/package-skills/comet-admin-form/SKILL.md new file mode 100644 index 0000000000..b618cc5938 --- /dev/null +++ b/package-skills/comet-admin-form/SKILL.md @@ -0,0 +1,234 @@ +--- +name: comet-admin-form +description: | + Generates Final Form components for a Comet DXP admin project using @comet/admin field components. All forms support create and edit modes, save conflict detection, and Apollo Client mutations. + TRIGGER when: user says "create a form for X", "add a form for X", "generate a form", "build an edit form for X", or any similar phrase requesting a data entry form component. Also trigger when any user or agent action involves creating, modifying, or working on admin form components (FinalForm, form fields, form validation, form mutations). +--- + +# Comet Form Skill + +Generate Final Form components by reading the GraphQL schema, determining fields, and producing form code following the patterns in `references/`. + +## Prerequisites + +1. **Read the GraphQL schema** for the target entity to determine: single entity query signature, `create` and `update` mutations with their input types, all field types, and whether the create mutation returns a payload with `errors` array. +2. **Include all fields by default.** Unless the user specifies otherwise, every field of the entity should be added to the form — most entities require all fields to be editable. +3. **Confirm output path** with the user if not obvious from context. + +## Core Imports + +| Import | Source | Purpose | +| ---------------------------- | ------------------ | -------------------------------------------------------------------- | +| `FinalForm` | `@comet/admin` | Form wrapper component | +| `useFormApiRef` | `@comet/admin` | Ref to access form API imperatively | +| `FinalFormSubmitEvent` | `@comet/admin` | Type for submit event parameter | +| `Field` | `@comet/admin` | Generic field wrapper | +| `TextField` | `@comet/admin` | Text input field | +| `TextAreaField` | `@comet/admin` | Multiline text field | +| `NumberField` | `@comet/admin` | Number input field | +| `CheckboxField` | `@comet/admin` | Checkbox boolean field | +| `SelectField` | `@comet/admin` | Select dropdown field | +| `SwitchField` | `@comet/admin` | Toggle switch boolean field | +| `AsyncAutocompleteField` | `@comet/admin` | Async autocomplete for relations | +| `AsyncSelectField` | `@comet/admin` | Async dropdown select for relations | +| `Future_DatePickerField` | `@comet/admin` | Date picker field | +| `Future_DateTimePickerField` | `@comet/admin` | DateTime picker field | +| `filterByFragment` | `@comet/admin` | Filter query data to match fragment shape | +| `Loading` | `@comet/admin` | Loading spinner component | +| `useStackSwitchApi` | `@comet/admin` | Stack navigation for redirect after create | +| `OnChangeField` | `@comet/admin` | Trigger side effects on field value change | +| `FieldSet` | `@comet/admin` | Collapsible section to group related fields | +| `BlockState` | `@comet/cms-admin` | Type for block field state | +| `createFinalFormBlock` | `@comet/cms-admin` | Integrate CMS block into Final Form | +| `FileUploadField` | `@comet/cms-admin` | File upload field (always use with downloadable fragment by default) | +| `DamImageBlock` | `@comet/cms-admin` | DAM image block component | +| `useContentScope` | `@comet/cms-admin` | Access current content scope | +| `queryUpdatedAt` | `@comet/cms-admin` | Query updatedAt for save conflict check | +| `resolveHasSaveConflict` | `@comet/cms-admin` | Resolve save conflict detection | +| `useFormSaveConflict` | `@comet/cms-admin` | Save conflict hook | + +## Generation Workflow + +### Step 1 — Resolve dependencies (BEFORE generating form files) + +For every **enum field**: + +1. Search for an existing reusable field component (e.g. glob `**/SelectField.tsx`, `**/RadioGroupField.tsx`, `**/CheckboxListField.tsx`) +2. If none exists — **stop and use the `comet-admin-translatable-enum` skill** to create one first + +For every **ManyToOne or ManyToMany relation field**: + +1. Search for an existing `AsyncAutocompleteField.tsx` +2. If none exists — generate it now (see [form-field-07-many-to-one.md](references/form-field-07-many-to-one.md)) +3. Ask the user for the correct domain path if unclear + +### Step 2 — Generate the GQL definitions and Form component + +**Always read [form-00-shell.md](references/form-00-shell.md) first** — it contains both the GQL and Form component base patterns. Then read the applicable field type files for each field. + +## Key Rules + +- The reference files cover core patterns and common usage. For any field component props, options, or features not explicitly documented here, **read the TypeScript type definitions** of the component (e.g., `TextField`, `NumberField`, `AsyncAutocompleteField`) to discover all available props and their types from @comet/admin or the project itself. The types are the source of truth — do not guess prop names or values. +- For every enum field, search for an existing reusable field component. If none exists, use the `translatable-enum` skill to create one first. +- ManyToOne/ManyToMany relation fields require a reusable `AsyncAutocompleteField` component — see [form-field-07-many-to-one.md](references/form-field-07-many-to-one.md). +- Always include `id` and `updatedAt` in the query (required for save conflict detection). +- Use exact mutation/query names from the schema — do not guess. +- Only include fields actually used in the form in the GQL fragment. +- Prefer `` over `intl.formatMessage()` wherever possible. Only use `intl.formatMessage()` when a prop requires a plain `string` type. +- Files ending in `.gql.generated.ts` are auto-generated by codegen — do not create them manually. +- Use `subscription={{}}` on `FinalForm` by default to minimize re-renders. Only add `subscription={{ values: true }}` when the form render function needs access to `values` (e.g. for conditional fields or dependent field logic). +- **Nullable number fields cause dirty handler issues.** `filterByFragment` returns `null` for nullable fields, but `NumberField` internally normalizes `null` → `undefined` on mount (via `useEffect`), which changes the form value and triggers dirty state. Always normalize nullable number fields in `initialValues` with `?? undefined` (e.g. `purchasePrice: data.entity.purchasePrice ?? undefined`). This applies to any field whose component normalizes values on mount. +- For scoped entities, forward the content scope to mutations using `useContentScope` from `@comet/cms-admin`. +- The form needs to be wired into a Stack page (not part of this skill unless asked). + +## Helper Text + +All field components support a `helperText` prop for providing guidance to the user. Add `helperText` when a field's purpose, expected format, or constraints are not immediately obvious from the label alone. + +```tsx +} + helperText={ + + } +/> +``` + +### When to add helperText + +- **Format constraints** — fields with specific formats (slugs, codes, patterns, URLs, email addresses) +- **Unit or range clarification** — number fields where the unit or valid range isn't clear from the label (e.g. "Weight in grams", "Value between 0 and 100") +- **Side effects** — fields where changing the value triggers behavior the user should know about (e.g. "Changing the slug will break existing links") +- **Relation context** — autocomplete fields where the user might not know what entity to search for or what the relation means +- **Non-obvious purpose** — fields with technical or domain-specific names that need plain-language explanation + +### When NOT to add helperText + +- Self-explanatory fields like "Title", "Description", "Name", "Email" +- Boolean fields where the `fieldLabel` already explains the behavior +- Enum fields where the options are self-descriptive + +## Field Type Reference + +Read the relevant field file based on the field type from the GraphQL schema: + +| Field Type | Component | Reference | +| ----------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| String | `TextField` | [form-field-01-string.md](references/form-field-01-string.md) | +| Text (multiline) | `TextAreaField` | [form-field-02-text.md](references/form-field-02-text.md) | +| Number / Int / Float | `NumberField` | [form-field-03-number.md](references/form-field-03-number.md) | +| Boolean | `CheckboxField` | [form-field-04-boolean.md](references/form-field-04-boolean.md) | +| DateTime / LocalDate | `Future_DatePickerField` / `Future_DateTimePickerField` | [form-field-05-datetime.md](references/form-field-05-datetime.md) | +| Enum | Reusable `SelectField` / `RadioGroupField` / `CheckboxListField` | [form-field-06-enum.md](references/form-field-06-enum.md) | +| ManyToOne relation (large set) | Reusable `AsyncAutocompleteField` | [form-field-07-many-to-one.md](references/form-field-07-many-to-one.md) | +| ManyToMany relation (large set) | Reusable `AsyncAutocompleteField` with `multiple` | [form-field-08-many-to-many.md](references/form-field-08-many-to-many.md) | +| Nested scalar object | Individual fields with dot notation | [form-field-09-nested-object.md](references/form-field-09-nested-object.md) | +| FileUpload | `FileUploadField` from `@comet/cms-admin` | [form-field-10-file-upload.md](references/form-field-10-file-upload.md) | +| Block (DamImage, RTE) | `Field` + `createFinalFormBlock` | [form-field-11-block.md](references/form-field-11-block.md) | +| Boolean (switch) | `SwitchField` | [form-field-12-switch.md](references/form-field-12-switch.md) | +| Relation (small set, no pagination) | `AsyncSelectField` | [form-field-13-async-select.md](references/form-field-13-async-select.md) | + +## Relation Field Component + +| Pattern | When to use | Reference | +| ---------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | +| AsyncAutocompleteField | Reusable relation field with search (large/paginated option sets) | [form-field-07-many-to-one.md](references/form-field-07-many-to-one.md) | +| AsyncSelectField | Inline relation dropdown (small option sets, no pagination) | [form-field-13-async-select.md](references/form-field-13-async-select.md) | + +## Layout & Patterns + +### FieldSet (default layout) + +**Always group form fields into `FieldSet` components by default**, unless the user explicitly requests a flat form or a different layout. Group fields by logical sections (e.g. "General", "Details", "Media", "Settings"). + +- All FieldSet should use `initiallyExpanded` so it's open by default +- Import `FieldSet` from `@comet/admin` + +```tsx +import { FieldSet } from "@comet/admin"; + +
}> + + + +
+
}> + + + +
+
}> + + {createFinalFormBlock(rootBlocks.image)} + +
+``` + +#### Grouping guidelines + +- **General / Main Data**: core identifying fields (name, title, slug, status, type) +- **Details / Additional Data**: secondary fields (prices, dates, quantities, dimensions) +- **Media**: block fields (images, files) +- **Relations**: relation fields (categories, tags, references) +- **Settings**: configuration fields (booleans, feature flags) + +Use your judgement to create sensible groups. A form with only 2-3 fields does not need FieldSets. + +| Pattern | When to use | Reference | +| --------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Conditional fields | Show/hide fields based on another field's value | [form-pattern-01-conditional-fields.md](references/form-pattern-01-conditional-fields.md) | +| Cross-field filtering | Filter async select options based on another field's value | [form-pattern-01-conditional-fields.md](references/form-pattern-01-conditional-fields.md) | +| Adornments | Add icons, units, or prefixes to input fields | [form-pattern-02-adornments.md](references/form-pattern-02-adornments.md) | +| Validation | Client-side field validation with error messages | [form-pattern-03-validation.md](references/form-pattern-03-validation.md) | +| Dividers | Visually separate groups of fields within a FieldSet | see below | + +### Dividers + +Use a styled `Divider` to visually separate sub-groups of fields within a single FieldSet. Dividers need vertical spacing via a styled component: + +```tsx +import { Divider, styled } from "@mui/material"; + +const FieldDivider = styled(Divider)(({ theme }) => ({ + marginTop: theme.spacing(4), + marginBottom: theme.spacing(4), +})); + +// Usage inside a FieldSet: + + + + + + +``` + +## Scoped Entities + +For entities that are scoped to a content scope (language, domain), forward the scope to mutations: + +```tsx +import { useContentScope } from "@comet/cms-admin"; + +const { scope } = useContentScope(); + +// In handleSubmit: +await client.mutate({ + mutation: createEntityMutation, + variables: { input: output, scope }, +}); +``` + +Add `scope` as a required variable in the GQL mutation if the schema requires it. + +## Pages & DataGrids + +- When the form needs to be wired into a page, use the **comet-admin-pages** skill for Stack/StackSwitch navigation patterns. +- When a page also includes a DataGrid alongside the form, use the **comet-admin-datagrid** skill to generate it. diff --git a/package-skills/comet-admin-form/references/form-00-shell.md b/package-skills/comet-admin-form/references/form-00-shell.md new file mode 100644 index 0000000000..61be629e16 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-00-shell.md @@ -0,0 +1,287 @@ +# Form Shell Template + +**Always read this file first** — it is the base pattern for every form component. + +## GQL Definitions + +```typescript +import { gql } from "@apollo/client"; +import { finalFormFileUploadFragment, finalFormFileUploadDownloadableFragment } from "@comet/cms-admin"; +// Only import the above if the entity has FileUpload fields + +export const FormFragment = gql` + fragment FormDetails on { + + + + + } + ${finalFormFileUploadFragment} // only if FileUpload fields without preview exist + ${finalFormFileUploadDownloadableFragment} // only if FileUpload fields with preview exist +`; + +export const Query = gql` + query ($id: ID!) { + (id: $id) { + id + updatedAt + ...FormDetails + } + } + ${FormFragment} +`; + +export const createMutation = gql` + mutation Create($input: Input!) { + create(input: $input) { + { + id + updatedAt + ...FormDetails + } + errors { + code + field + } + } + } + ${FormFragment} +`; +// Only include errors{} if the create mutation returns a payload type with errors + +export const updateMutation = gql` + mutation Update($id: ID!, $input: UpdateInput!) { + update(id: $id, input: $input) { + id + updatedAt + ...FormDetails + } + } + ${FormFragment} +`; +``` + +### Form Modes + +The shell template above shows the default **create + edit** mode (`id` prop is optional). Some forms only need one mode: + +**Edit-only form** — when entities are created elsewhere (e.g. via import, or by another system): + +- `FormProps` has `id: string` (required, not optional) +- No `mode` variable needed — always `"edit"` +- No create mutation needed +- No `stackSwitchApi` needed +- `useQuery` always runs (no `skip` condition) +- `FinalForm` gets `mode="edit"` + +**Add-only form** — when used in a dialog that only creates (e.g. `EditDialog` for quick creation): + +- `FormProps` has no `id` prop +- No `mode` variable needed — always `"add"` +- No entity query needed +- No `useFormSaveConflict` needed (no existing data to conflict with) +- No `filterByFragment` / `initialValues` from query +- `FinalForm` gets `mode="add"` +- Typically receives an `onCreate` callback prop instead of using `stackSwitchApi` + +### Fragment field rules by type + +| Field type | Fragment entry | +| ----------------------------- | -------------------------------------------------- | +| Scalar (string, number, bool) | Field name directly (e.g. `title`) | +| DateTime / LocalDate | Field name directly (e.g. `createdAt`) | +| Enum | Field name directly (e.g. `status`) | +| ManyToOne relation | `category { id title }` (id + label field) | +| ManyToMany relation | `tags { id title }` (id + label field) | +| Nested scalar object | `dimensions { width height depth }` | +| FileUpload (with preview) | `priceList { ...FinalFormFileUploadDownloadable }` | +| FileUpload (without preview) | `datasheets { ...FinalFormFileUpload }` | +| DAM Image | Skip | +| Array of scalars | Skip | + +### GQL Rules + +- Always include `id` and `updatedAt` in the query (required for save conflict detection) +- Use exact mutation/query names and input types from the schema — do not guess. Some entities use the same input type for both create and update +- Only import `finalFormFileUploadFragment` / `finalFormFileUploadDownloadableFragment` when needed +- If create mutation returns a simple type (not a payload with errors), omit the errors block and adjust the create mutation response accordingly + +--- + +## Form Component + +```tsx +import { useApolloClient, useQuery } from "@apollo/client"; +import { + CheckboxField, + Field, + filterByFragment, + FinalForm, + FinalFormSubmitEvent, + Loading, + NumberField, + TextAreaField, + TextField, + useFormApiRef, + useStackSwitchApi, + Future_DatePickerField, + Future_DateTimePickerField, + SelectField, // if enum fields exist +} from "@comet/admin"; +import { FileUploadField, queryUpdatedAt, resolveHasSaveConflict, useFormSaveConflict } from "@comet/cms-admin"; +import { FORM_ERROR, FormApi } from "final-form"; +import isEqual from "lodash.isequal"; +import { ReactNode, useMemo } from "react"; +import { FormattedMessage } from "react-intl"; + +import { FormFragment, Query, createMutation, updateMutation } from "./Form.gql"; +import { + GQLFormDetailsFragment, + GQLQuery, + GQLQueryVariables, + GQLCreateMutation, + GQLCreateMutationVariables, + GQLUpdateMutation, + GQLUpdateMutationVariables, +} from "./Form.gql.generated"; +import { GQLMutationErrorCode } from "@src/graphql.generated"; +// Import reusable relation field components: +import { AsyncAutocompleteField } from "@src//components/AsyncAutocompleteField/AsyncAutocompleteField"; +// Import reusable enum field components: +import { SelectField } from "@src//components//SelectField"; + +// Only include FileUpload type overrides if entity has FileUpload fields: +import { GQLFinalFormFileUploadFragment, GQLFinalFormFileUploadDownloadableFragment } from "@comet/cms-admin"; + +type FormDetailsFragment = OmitFormDetailsFragment, "priceList" | "datasheets"> & { + priceList: GQLFinalFormFileUploadDownloadableFragment | null; + datasheets: GQLFinalFormFileUploadFragment[]; +}; + +type FormValues = FormDetailsFragment; +// Omit fields that need type transformation, add transformed types: +// type FormValues = Omit<FormDetailsFragment, "lastCheckedAt"> & { +// lastCheckedAt?: Date | null; +// }; + +interface FormProps { + id?: string; +} + +// Only include if create mutation has errors payload: +const submissionErrorMessages: RecordMutationErrorCode, ReactNode> = { + // errorCode: , +}; + +export function Form({ id }: FormProps) { + const client = useApolloClient(); + const mode = id ? "edit" : "add"; + const formApiRef = useFormApiRef(); + const stackSwitchApi = useStackSwitchApi(); + + const { data, error, loading, refetch } = useQueryQuery, GQLQueryVariables>( + Query, + id ? { variables: { id } } : { skip: true }, + ); + + const initialValues = useMemo>( + () => + data?. + ? { + ...filterByFragment<FormDetailsFragment>(FormFragment, data.), + // Transform DateTime fields: + // lastCheckedAt: data..lastCheckedAt ? new Date(data..lastCheckedAt) : undefined, + // Normalize nullable number fields (null → undefined) to prevent dirty handler issues: + // purchasePrice: data..purchasePrice ?? undefined, + } + : { + // sensible defaults for new records + }, + [data], + ); + + const saveConflict = useFormSaveConflict({ + checkConflict: async () => { + const updatedAt = await queryUpdatedAt(client, "", id); + return resolveHasSaveConflict(data?..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, + // Transform relation fields to IDs: + // category: formValues.category ? formValues.category.id : null, + // tags: formValues.tags.map((item) => item.id), + // Transform DateTime fields: + // lastCheckedAt: formValues.lastCheckedAt ? formValues.lastCheckedAt.toISOString() : null, + // Transform FileUpload fields: + // priceList: formValues.priceList ? formValues.priceList.id : null, + // datasheets: formValues.datasheets?.map(({ id }) => id), + // Remove read-only fields not in UpdateInput: + // createdAt: undefined, (strip audit fields from update) + }; + + if (mode === "edit") { + if (!id) throw new Error(); + await client.mutateMutation, GQLUpdateMutationVariables>({ + mutation: updateMutation, + variables: { id, input: output }, + }); + } else { + const { data: mutationResponse } = await client.mutateMutation, GQLCreateMutationVariables>({ + mutation: createMutation, + variables: { input: output }, + }); + + // Only include error handling if create mutation has errors payload: + if (mutationResponse?.create.errors.length) { + return mutationResponse.create.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?.create.?.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} + {/* Fields go here */} + + )} + + ); +} +``` diff --git a/package-skills/comet-admin-form/references/form-field-01-string.md b/package-skills/comet-admin-form/references/form-field-01-string.md new file mode 100644 index 0000000000..ba4148e113 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-01-string.md @@ -0,0 +1,18 @@ +# String Field + +## Component + +```tsx +} +/> +``` + +## Rules + +- Use `required` when the field is non-nullable in the GraphQL schema +- Import `TextField` from `@comet/admin` diff --git a/package-skills/comet-admin-form/references/form-field-02-text.md b/package-skills/comet-admin-form/references/form-field-02-text.md new file mode 100644 index 0000000000..ed26f5f984 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-02-text.md @@ -0,0 +1,19 @@ +# Text (Multiline) Field + +For longer nullable string fields. + +## Component + +```tsx +} +/> +``` + +## Rules + +- Import `TextAreaField` from `@comet/admin` +- Use for longer text fields; use `TextField` for short single-line strings diff --git a/package-skills/comet-admin-form/references/form-field-03-number.md b/package-skills/comet-admin-form/references/form-field-03-number.md new file mode 100644 index 0000000000..7909e9058d --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-03-number.md @@ -0,0 +1,48 @@ +# Number / Int / Float Field + +## Component + +```tsx +} +/> +``` + +## Currency fields + +When a number field represents a price, cost, or any monetary value (e.g. field names like `price`, `purchasePrice`, `cost`, `amount`, `fee`, `budget`, `revenue`), add a currency adornment. Default to `€` (EUR) unless the user specifies a different currency. + +```tsx +import { InputAdornment } from "@mui/material"; + +} + endAdornment={} +/>; +``` + +## Nested object (dot notation) + +```tsx +} +/> +``` + +## Rules + +- Import `NumberField` from `@comet/admin` +- Use dot notation for nested object fields (e.g. `dimensions.width`) +- **Currency fields**: When a number field is tentatively a currency/price (based on field name or context), always add an `endAdornment` with the currency symbol. Use `€` as default, ask the user if unclear. +- For other units (weight, distance, percentage), also consider adding an appropriate `endAdornment` (e.g. `kg`, `m`, `%`) diff --git a/package-skills/comet-admin-form/references/form-field-04-boolean.md b/package-skills/comet-admin-form/references/form-field-04-boolean.md new file mode 100644 index 0000000000..cf9913cd3b --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-04-boolean.md @@ -0,0 +1,13 @@ +# Boolean Field + +## Component + +```tsx +} name="inStock" fullWidth variant="horizontal" /> +``` + +## Rules + +- Import `CheckboxField` from `@comet/admin` +- Uses `fieldLabel` prop (not `label`) — this is different from other field components +- For toggles that trigger an immediate visible response (e.g. showing/hiding a FieldSet, activating a feature), prefer `SwitchField` instead — see [form-field-12-switch.md](form-field-12-switch.md) diff --git a/package-skills/comet-admin-form/references/form-field-05-datetime.md b/package-skills/comet-admin-form/references/form-field-05-datetime.md new file mode 100644 index 0000000000..d33e5661c9 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-05-datetime.md @@ -0,0 +1,56 @@ +# DateTime / LocalDate Fields + +## Read-only / audit (createdAt, updatedAt) + +```tsx +import { Lock } from "@comet/admin-icons"; +import { InputAdornment } from "@mui/material"; + + + + + } + variant="horizontal" + fullWidth + name="createdAt" + label={} +/>; +``` + +## Editable DateTime + +```tsx +} +/> +``` + +Requires type transformation in `FormValues` (`Date | null`) and in `handleSubmit` (`.toISOString()`). + +## Editable LocalDate + +```tsx +} +/> +``` + +## Rules + +- Import `Future_DatePickerField` and `Future_DateTimePickerField` from `@comet/admin` +- **Editable DateTime** requires transformations: + - `FormValues` type: `Omit<..., "lastCheckedAt"> & { lastCheckedAt?: Date | null }` + - `initialValues`: `new Date(data..lastCheckedAt)` + - `handleSubmit` output: `formValues.lastCheckedAt ? formValues.lastCheckedAt.toISOString() : null` +- **LocalDate** does not need type transformation +- **Read-only** fields (audit timestamps) should strip the field from `handleSubmit` output: `createdAt: undefined` diff --git a/package-skills/comet-admin-form/references/form-field-06-enum.md b/package-skills/comet-admin-form/references/form-field-06-enum.md new file mode 100644 index 0000000000..4e2249781c --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-06-enum.md @@ -0,0 +1,28 @@ +# Enum Fields + +**Always use a reusable field from the `comet-admin-translatable-enum` skill. Check first, create if missing.** + +## Single value + +- <=4 options: use `RadioGroupField` +- >4 options: use `SelectField` + +```tsx +} /> +``` + +## Multiple values (array) + +Typically `CheckboxListField` or `SelectField` with `multiple`. + +```tsx +} +/> +``` + +## Rules + +- Always search for an existing reusable enum field component first (glob `**/SelectField.tsx`, `**/RadioGroupField.tsx`, `**/CheckboxListField.tsx`) +- If none exists, use the `comet-admin-translatable-enum` skill to create one before generating the form diff --git a/package-skills/comet-admin-form/references/form-field-07-many-to-one.md b/package-skills/comet-admin-form/references/form-field-07-many-to-one.md new file mode 100644 index 0000000000..3e04bbebe4 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-07-many-to-one.md @@ -0,0 +1,225 @@ +# ManyToOne Relation Field (AsyncAutocompleteField) + +**Always generate a reusable `AsyncAutocompleteField` component.** + +**When to use:** The API query supports **pagination and a `search` parameter** — the option set is large or unbounded. If the API query returns all items without pagination, use [AsyncSelectField](form-field-13-async-select.md) instead. + +## Creating the reusable component + +Generate one `AsyncAutocompleteField` component per relation. + +Always check if the component already exists before generating. If it exists, reuse it. +Ask the user for the correct domain path if it's not obvious from the schema. + +### GQL Definitions + +```ts +import { gql } from "@apollo/client"; + +export const productCategoryAsyncAutocompleteFieldFragment = gql` + fragment ProductCategoryAsyncAutocompleteFieldProductCategory on ProductCategory { + id + title + } +`; + +export const productCategoryAsyncAutocompleteFieldQuery = gql` + query ProductCategoryAsyncAutocompleteField($search: String) { + productCategories(search: $search) { + nodes { + ...ProductCategoryAsyncAutocompleteFieldProductCategory + } + } + } + ${productCategoryAsyncAutocompleteFieldFragment} +`; +``` + +### Component + +```tsx +import { useApolloClient } from "@apollo/client"; +import { AsyncAutocompleteField, type AsyncAutocompleteFieldProps } from "@comet/admin"; +import { type FunctionComponent } from "react"; + +import { + productCategoryAsyncAutocompleteFieldFragment, + productCategoryAsyncAutocompleteFieldQuery, +} from "./ProductCategoryAsyncAutocompleteField.gql"; +import { + type GQLProductCategoryAsyncAutocompleteFieldProductCategoryFragment, + type GQLProductCategoryAsyncAutocompleteFieldQuery, + type GQLProductCategoryAsyncAutocompleteFieldQueryVariables, +} from "./ProductCategoryAsyncAutocompleteField.gql.generated"; + +export type ProductCategoryAsyncAutocompleteFieldOption = GQLProductCategoryAsyncAutocompleteFieldProductCategoryFragment; + +type ProductCategoryAsyncAutocompleteFieldProps = Omit< + AsyncAutocompleteFieldProps, + "loadOptions" +>; + +export const ProductCategoryAsyncAutocompleteField: FunctionComponent = ({ + name, + clearable = true, + disabled = false, + variant = "horizontal", + fullWidth = true, + ...restProps +}) => { + const client = useApolloClient(); + + return ( + option.title} + isOptionEqualToValue={(option, value) => option.id === value.id} + {...restProps} + loadOptions={async (search) => { + const { data } = await client.query< + GQLProductCategoryAsyncAutocompleteFieldQuery, + GQLProductCategoryAsyncAutocompleteFieldQueryVariables + >({ + query: productCategoryAsyncAutocompleteFieldQuery, + variables: { search }, + }); + return data.productCategories.nodes; + }} + /> + ); +}; +``` + +--- + +## With additional query variables + +When the API query requires extra variables (e.g. a content scope, a filter, or a parent entity ID), add them as component props: + +### GQL Definitions + +```ts +import { gql } from "@apollo/client"; + +export const productAsyncAutocompleteFieldFragment = gql` + fragment ProductAsyncAutocompleteFieldProduct on Product { + id + title + } +`; + +export const productAsyncAutocompleteFieldQuery = gql` + query ProductAsyncAutocompleteField($search: String, $filter: ProductFilter, $scope: ProductContentScopeInput!) { + products(search: $search, filter: $filter, scope: $scope) { + nodes { + ...ProductAsyncAutocompleteFieldProduct + } + } + } + ${productAsyncAutocompleteFieldFragment} +`; +``` + +### Component + +```tsx +import { useApolloClient } from "@apollo/client"; +import { AsyncAutocompleteField, type AsyncAutocompleteFieldProps } from "@comet/admin"; +import { useContentScope } from "@comet/cms-admin"; +import { type FunctionComponent } from "react"; + +import { productAsyncAutocompleteFieldQuery } from "./ProductAsyncAutocompleteField.gql"; +import { + type GQLProductAsyncAutocompleteFieldProductFragment, + type GQLProductAsyncAutocompleteFieldQuery, + type GQLProductAsyncAutocompleteFieldQueryVariables, +} from "./ProductAsyncAutocompleteField.gql.generated"; + +export type ProductAsyncAutocompleteFieldOption = GQLProductAsyncAutocompleteFieldProductFragment; + +type ProductAsyncAutocompleteFieldProps = Omit< + AsyncAutocompleteFieldProps, + "loadOptions" +> & { + categoryId?: string; +}; + +export const ProductAsyncAutocompleteField: FunctionComponent = ({ + name, + categoryId, + clearable = true, + disabled = false, + variant = "horizontal", + fullWidth = true, + ...restProps +}) => { + const client = useApolloClient(); + const { scope } = useContentScope(); + + return ( + option.title} + isOptionEqualToValue={(option, value) => option.id === value.id} + {...restProps} + loadOptions={async (search) => { + const { data } = await client.query({ + query: productAsyncAutocompleteFieldQuery, + variables: { + search, + scope, + filter: categoryId ? { category: { equal: categoryId } } : undefined, + }, + }); + return data.products.nodes; + }} + /> + ); +}; +``` + +--- + +## Usage in a form + +```tsx +// Single relation (ManyToOne) +} +/> + +// With additional filter prop +} + categoryId={values.category?.id} + disabled={!values?.category} +/> +``` + +## handleSubmit output + +```ts +category: formValues.category ? formValues.category.id : null, +``` + +## Rules + +- Search for an existing `AsyncAutocompleteField.tsx` before generating a new one +- `` = the human-readable label field of the related entity (e.g. `title`, `name`) — ask user if unclear +- The query must accept a `$search: String` parameter — this enables server-side filtering as the user types +- If the API query requires additional variables (e.g. scope, filter, parent ID), add them as component props and forward to `variables` +- The relation object (with `id` + label field) is stored in form values, then transformed to just the ID in `handleSubmit` +- The `multiple` prop is passed through `...restProps` — no special handling needed in the component +- `AsyncAutocompleteField.gql.generated.ts` is auto-generated by codegen — do not create manually +- Fragment name convention: `AsyncAutocompleteField` (e.g. `ProductCategoryAsyncAutocompleteFieldProductCategory`) +- GQL fragment entry in the form query: `category { id title }` (id + label field) diff --git a/package-skills/comet-admin-form/references/form-field-08-many-to-many.md b/package-skills/comet-admin-form/references/form-field-08-many-to-many.md new file mode 100644 index 0000000000..fa3cfa06b7 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-08-many-to-many.md @@ -0,0 +1,45 @@ +# ManyToMany Relation Field + +Same reusable `AsyncAutocompleteField` component as ManyToOne, with `multiple` prop — see [form-field-07-many-to-one.md](form-field-07-many-to-one.md) for how to create the component. + +## Component + +```tsx +} /> +``` + +## handleSubmit output + +```ts +tags: formValues.tags.map((item) => item.id), +``` + +## Custom chip rendering with `renderValue` + +When multi-select chips need custom rendering (e.g. showing a color indicator, icon, or extra info), use `renderValue` on the `AsyncAutocompleteField`. **Do NOT use `renderTags` — it is deprecated.** + +```tsx +import { Chip } from "@mui/material"; + + + (value as OptionType[]).map((option, index) => { + const itemProps = getItemProps({ index }); + return } {...itemProps} />; + }) + } +/>; +``` + +**Key differences from the deprecated `renderTags`:** + +- `getItemProps` does NOT return a `key` property — use `option.id` as key instead +- `value` type is a union — cast to your option type: `(value as OptionType[])` +- Use `renderOption` alongside `renderValue` to customize the dropdown items too + +## Rules + +- Search for an existing `AsyncAutocompleteField.tsx` before generating a new one +- The `multiple` prop is passed through `...restProps` in the component — no special handling needed +- GQL fragment entry: `tags { id title }` (id + label field) diff --git a/package-skills/comet-admin-form/references/form-field-09-nested-object.md b/package-skills/comet-admin-form/references/form-field-09-nested-object.md new file mode 100644 index 0000000000..812a1eac53 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-09-nested-object.md @@ -0,0 +1,17 @@ +# Nested Scalar Object Fields + +Group related fields together using dot notation. + +## Component + +```tsx +} /> +} /> +} /> +``` + +## Rules + +- Use dot notation for nested fields (e.g. `dimensions.width`) +- Each nested property gets its own field component — use the appropriate type (`NumberField`, `TextField`, etc.) +- GQL fragment entry: `dimensions { width height depth }` diff --git a/package-skills/comet-admin-form/references/form-field-10-file-upload.md b/package-skills/comet-admin-form/references/form-field-10-file-upload.md new file mode 100644 index 0000000000..ea9f2110c1 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-10-file-upload.md @@ -0,0 +1,56 @@ +# FileUpload Fields + +**Default: Always use `FinalFormFileUploadDownloadable` fragment** for all file upload fields. This enables image previews and download URLs. Only fall back to `FinalFormFileUpload` when explicitly requested or when files are guaranteed to never be images (e.g. CSVs, XML imports). + +## Single file + +```tsx +} + variant="horizontal" + fullWidth +/> +``` + +- Import `FileUploadField` from `@comet/cms-admin` +- `FormValues` type: `certificate: GQLFinalFormFileUploadDownloadableFragment | null` +- GQL fragment: `certificate { ...FinalFormFileUploadDownloadable }` +- Import fragment: `import { finalFormFileUploadDownloadableFragment } from "@comet/cms-admin"` +- `handleSubmit` output: `certificateFileUploadId: formValues.certificate ? formValues.certificate.id : null` + +## Multiple files + +```tsx +} + variant="horizontal" + fullWidth + multiple + layout="grid" +/> +``` + +- `FormValues` type: `photos: GQLFinalFormFileUploadDownloadableFragment[]` +- GQL fragment: `photos { ...FinalFormFileUploadDownloadable }` +- Import fragment: `import { finalFormFileUploadDownloadableFragment } from "@comet/cms-admin"` +- `handleSubmit` output: `photoFileUploadIds: formValues.photos?.map(({ id }) => id)` +- Use `layout="grid"` for a visual grid of image thumbnails + +## Fragment choice + +| Fragment | When to use | +| --------------------------------- | ------------------------------------------------------------- | +| `FinalFormFileUploadDownloadable` | **Default** — use for all file uploads | +| `FinalFormFileUpload` | Only when no preview or download is needed (e.g. CSV imports) | + +Both fragments work with single and multiple file uploads — the choice is about preview, not cardinality. + +## Rules + +- Import `FileUploadField` from `@comet/cms-admin` +- Import `GQLFinalFormFileUploadDownloadableFragment` from `@comet/cms-admin` for type overrides (default) +- The form shell needs a local type override for `FormValues` to replace the GQL fragment types with the FileUpload fragment types (see [form-00-shell.md](form-00-shell.md)) +- Only import `finalFormFileUploadDownloadableFragment` when the entity has FileUpload fields +- Always use the `FinalFormFileUploadDownloadable` fragment unless there is a specific reason not to diff --git a/package-skills/comet-admin-form/references/form-field-11-block.md b/package-skills/comet-admin-form/references/form-field-11-block.md new file mode 100644 index 0000000000..6bc7742ebc --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-11-block.md @@ -0,0 +1,73 @@ +# Block Fields (DamImageBlock, RTE, etc.) + +Block fields integrate CMS block components (DAM images, rich text editors, custom blocks) into Final Form. They require special state/output conversion. + +## Setup + +Collect all block fields into a `rootBlocks` object: + +```tsx +import { BlockState, createFinalFormBlock } from "@comet/cms-admin"; +import { DamImageBlock } from "@comet/cms-admin"; +import isEqual from "lodash.isequal"; + +const rootBlocks = { + image: DamImageBlock, +}; +``` + +## FormValues type + +Block fields must override the GQL fragment type with `BlockState`: + +```tsx +type FormValues = Omit & { + image: BlockState; +}; +``` + +## initialValues + +Convert GQL data to block state using `input2State`: + +```tsx +const initialValues = useMemo>( + () => + data?.product + ? { + ...filterByFragment<...>(productFormFragment, data.product), + image: rootBlocks.image.input2State(data.product.image), + } + : { + image: rootBlocks.image.defaultValues(), + }, + [data], +); +``` + +## handleSubmit output + +Convert block state back to GQL output using `state2Output`: + +```ts +image: rootBlocks.image.state2Output(formValues.image), +``` + +## Component + +```tsx +} variant="horizontal" fullWidth> + {createFinalFormBlock(rootBlocks.image)} + +``` + +## GQL fragment + +Block fields are **not included in the GQL fragment** directly — the block component handles its own sub-query. The fragment type is overridden via `Omit` + `BlockState`. + +## Rules + +- `isEqual` prop on `` is **required** for block fields to detect changes correctly +- Use `input2State` for edit mode initialization and `defaultValues()` for add mode +- Use `state2Output` in handleSubmit to convert back to GQL format +- Import `BlockState`, `createFinalFormBlock`, and block components (e.g. `DamImageBlock`) from `@comet/cms-admin` diff --git a/package-skills/comet-admin-form/references/form-field-12-switch.md b/package-skills/comet-admin-form/references/form-field-12-switch.md new file mode 100644 index 0000000000..500c769ba7 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-12-switch.md @@ -0,0 +1,18 @@ +# Switch Field + +Alternative to `CheckboxField` for boolean values. Renders a toggle switch with customizable labels. + +## Component + +```tsx +import { SwitchField } from "@comet/admin"; + +} variant="horizontal" fullWidth />; +``` + +## Rules + +- Import `SwitchField` from `@comet/admin` +- Uses `fieldLabel` prop (same as `CheckboxField`) +- Prefer `SwitchField` when toggling causes an immediate visible response (e.g. showing/hiding a FieldSet, activating a feature) +- Prefer `CheckboxField` for simple boolean values without immediate UI feedback (e.g. "Accept terms", "In Stock") diff --git a/package-skills/comet-admin-form/references/form-field-13-async-select.md b/package-skills/comet-admin-form/references/form-field-13-async-select.md new file mode 100644 index 0000000000..b107327bf5 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-field-13-async-select.md @@ -0,0 +1,95 @@ +# AsyncSelectField (Dropdown Relation) + +Alternative to `AsyncAutocompleteField` for relation fields. Renders a dropdown select that loads options asynchronously. Use when the number of options is small and a full autocomplete search is unnecessary. + +**When to use:** The API query returns **all items at once without pagination** (no `search` parameter). The option list is small (< ~50 items). If the API supports pagination and search, use [AsyncAutocompleteField](form-field-07-many-to-one.md) instead. + +## Basic usage + +```tsx +} + loadOptions={async () => { + const { data } = await client.query({ + query: gql` + query ProductCategoriesSelect { + productCategories { + nodes { + id + title + } + } + } + `, + }); + return data.productCategories.nodes; + }} + getOptionLabel={(option) => option.title} +/> +``` + +## Multiple selection + +Add the `multiple` prop for ManyToMany relations: + +```tsx +} + multiple + loadOptions={async () => { + const { data } = await client.query({ + query: gql` + query ProductTagsSelect { + productTags { + nodes { + id + title + } + } + } + `, + }); + return data.productTags.nodes; + }} + getOptionLabel={(option) => option.title} +/> +``` + +## Dependent AsyncSelectFields + +For dependent selects (where one field's options depend on another field's value), see [form-pattern-01-conditional-fields.md](form-pattern-01-conditional-fields.md#pattern-cross-field-filtering-for-async-select-fields). + +## When to use AsyncSelectField vs AsyncAutocompleteField + +| Criteria | AsyncSelectField | AsyncAutocompleteField | +| ------------------ | -------------------------------- | ----------------------------- | +| Option set size | Small (< ~50) | Large / unbounded | +| API query | Returns all items, no pagination | Paginated with `search` param | +| Search support | No | Yes (server-side) | +| UX pattern | Dropdown select | Autocomplete with text input | +| Component style | Inline in form | Separate reusable component | +| Reuse across forms | Typically no | Yes | + +## handleSubmit output + +```ts +// Single relation +category: formValues.category ? formValues.category.id : null, + +// Multiple relation +tags: formValues.tags ? formValues.tags.map((tag) => tag.id) : [], +``` + +## Rules + +- Import `AsyncSelectField` from `@comet/admin` +- The `loadOptions` callback takes **no arguments** (unlike AsyncAutocompleteField which receives `search`) +- After adding GQL queries, run codegen (codegen watcher) so the generated types become available +- Prefer `AsyncAutocompleteField` (as reusable component) when the field is used across multiple forms +- Prefer `AsyncSelectField` for simple, form-specific relations with few options and no pagination diff --git a/package-skills/comet-admin-form/references/form-layout-01-fieldset.md b/package-skills/comet-admin-form/references/form-layout-01-fieldset.md new file mode 100644 index 0000000000..9530ffa009 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-layout-01-fieldset.md @@ -0,0 +1,24 @@ +# FieldSet + +`FieldSet` groups related form fields into collapsible sections directly inside the form component. + +## Usage + +```tsx +import { FieldSet } from "@comet/admin"; + +
}> + + +
+
}> + + +
+``` + +## Rules + +- Always use `initiallyExpanded` so sections are open by default +- Import `FieldSet` from `@comet/admin` +- See the SKILL.md "Layout & Patterns" section for grouping guidelines diff --git a/package-skills/comet-admin-form/references/form-pattern-01-conditional-fields.md b/package-skills/comet-admin-form/references/form-pattern-01-conditional-fields.md new file mode 100644 index 0000000000..bdb4b1c28f --- /dev/null +++ b/package-skills/comet-admin-form/references/form-pattern-01-conditional-fields.md @@ -0,0 +1,206 @@ +# Conditional Field Visibility + +Show or hide fields based on another field's value. Uses Final Form's `Field` with selective subscription and MUI's `Collapse` for smooth animation. + +## Pattern: Toggle visibility with a boolean field + +```tsx +import { Field } from "@comet/admin"; +import { Collapse } from "@mui/material"; + +{ + /* Toggle field */ +} +} + variant="horizontal" + fullWidth +/>; + +{ + /* Conditional fields */ +} + + {({ input: { value } }) => ( + + } + /> + } + /> + + )} +; +``` + +## Pattern: Reset dependent field when parent changes + +Use `OnChangeField` to reset a field when its dependency changes: + +```tsx +import { OnChangeField } from "@comet/admin"; + +} + {...countryAutocompleteProps} +/> + + + {(value, previousValue) => { + if (value?.id !== previousValue?.id) { + form.change("manufacturer", undefined); + } + }} + + +} + disabled={!values?.country} + {...manufacturerAutocompleteProps} +/> +``` + +## Pattern: Cross-field filtering for async select fields + +When one async select field should filter its options based on another field's value (e.g. selecting a category first, then filtering products by that category), extract the dependent field into a reusable component that accepts a filter prop. + +### Reusable filtered component + +Create a reusable `AsyncSelectField` (or `AsyncAutocompleteField` for large option sets) that accepts filter variables as a prop. Place it in the related entity's domain folder, following the same pattern as [form-field-07-many-to-one.md](form-field-07-many-to-one.md). + +**GQL Definitions** + +```ts +import { gql } from "@apollo/client"; + +export const AsyncSelectFieldQuery = gql` + query AsyncSelectField($filter: Filter) { + s(filter: $filter) { + nodes { + id + + } + } + } +`; +``` + +**Component** + +```tsx +import { useApolloClient } from "@apollo/client"; +import { AsyncSelectField, type AsyncSelectFieldProps } from "@comet/admin"; +import { type FunctionComponent } from "react"; + +import { AsyncSelectFieldQuery } from "./AsyncSelectField.gql"; +import { + type GQLAsyncSelectFieldQuery, + type GQLAsyncSelectFieldQueryVariables, +} from "./AsyncSelectField.gql.generated"; + +type AsyncSelectFieldOption = GQLAsyncSelectFieldQuery["s"]["nodes"][number]; + +type AsyncSelectFieldProps = Omit< + AsyncSelectFieldProps<AsyncSelectFieldOption>, + "loadOptions" | "getOptionLabel" +> & { + filter?: GQLAsyncSelectFieldQueryVariables["filter"]; +}; + +export const AsyncSelectField: FunctionComponent<AsyncSelectFieldProps> = ({ + filter, + variant = "horizontal", + fullWidth = true, + ...restProps +}) => { + const client = useApolloClient(); + + return ( + option.} + {...restProps} + loadOptions={async () => { + const { data } = await client.query< + GQLAsyncSelectFieldQuery, + GQLAsyncSelectFieldQueryVariables + >({ + query: AsyncSelectFieldQuery, + variables: { filter }, + }); + return data.s.nodes; + }} + /> + ); +}; +``` + +### Usage in the form + +```tsx +import { OnChangeField } from "@comet/admin"; +import { ProductAsyncSelectField } from "@src/products/components/productAsyncSelectField/ProductAsyncSelectField"; + +{ + /* Parent field */ +} +; + +{ + /* Reset dependent field when parent changes */ +} + + {(value, previousValue) => { + if (value?.id !== previousValue?.id) { + form.change("product", undefined); + } + }} +; + +{ + /* Dependent field — filtered by parent value */ +} +} + disabled={!values?.category} + filter={{ category: { equal: values.category?.id } }} +/>; +``` + +### Key points + +- Extract the filtered field into a reusable component with a `filter` prop — same approach as reusable `AsyncAutocompleteField` components +- The dependent field is `disabled` until the parent has a value +- `OnChangeField` resets the dependent field when the parent changes to avoid stale selections +- The FinalForm must use `subscription={{ values: true }}` and the render function must expose both `values` and `form` + +## handleSubmit — strip conditional fields + +When a toggle controls optional nested fields, strip them if the toggle is off: + +```ts +const output = { + ...formValues, + alternativeAddress: formValues.useAlternativeAddress ? formValues.alternativeAddress : null, + useAlternativeAddress: undefined, // virtual field, not in GQL input +}; +``` + +## Rules + +- Use `subscription={{ value: true }}` on the `Field` wrapper to minimize re-renders — only re-render when the watched value changes +- Import `OnChangeField` from `@comet/admin` for dependent field resets +- When using `OnChangeField`, the FinalForm `subscription` must include `values: true` and the render function must expose `form` +- Always handle the disabled/hidden fields in `handleSubmit` — strip or null them when their toggle is off diff --git a/package-skills/comet-admin-form/references/form-pattern-02-adornments.md b/package-skills/comet-admin-form/references/form-pattern-02-adornments.md new file mode 100644 index 0000000000..f43434374e --- /dev/null +++ b/package-skills/comet-admin-form/references/form-pattern-02-adornments.md @@ -0,0 +1,64 @@ +# Field Adornments + +Start and end adornments can be added to text, number, and date fields to show icons, units, or other contextual information. + +## Pattern + +```tsx +import { InputAdornment } from "@mui/material"; +import { Euro, Lock } from "@comet/admin-icons"; + +{ + /* Unit adornment */ +} +} + endAdornment={kg} +/>; + +{ + /* Icon adornment */ +} +} + startAdornment={ + + + + } +/>; + +{ + /* Read-only field with lock icon */ +} +} + endAdornment={ + + + + } +/>; +``` + +## Rules + +- Import `InputAdornment` from `@mui/material` +- Import icons from `@comet/admin-icons` +- Supported on: `TextField`, `TextAreaField`, `NumberField`, `Future_DatePickerField`, `Future_DateTimePickerField` +- Use `startAdornment` for currency symbols or prefixes +- Use `endAdornment` for units (kg, cm, %) or status icons (Lock for read-only) +- Read-only fields should combine `readOnly`, `disabled`, and a `` end adornment diff --git a/package-skills/comet-admin-form/references/form-pattern-03-validation.md b/package-skills/comet-admin-form/references/form-pattern-03-validation.md new file mode 100644 index 0000000000..b194ec2628 --- /dev/null +++ b/package-skills/comet-admin-form/references/form-pattern-03-validation.md @@ -0,0 +1,167 @@ +# Field Validation + +All field components (`TextField`, `NumberField`, `SelectField`, etc.) support a `validate` prop from Final Form. Use it to add client-side validation that shows an error message below the field. + +## Rules + +- **Never create validator functions inside a render function or component body.** Validators must be stable references — creating them inline causes the field to re-register on every render. Always define validators in a separate file and import them. +- Validator functions return `undefined` when valid, or a `ReactElement` (typically ``) when invalid. +- For parameterized validators (e.g. max length), use a factory function that returns the validator. +- Validators live in a shared directory (e.g. `src/common/validators/`) and are reused across forms. +- Use `` for error messages (not `intl.formatMessage()`) to keep them translatable and as `ReactElement`. + +## Simple Validator + +```tsx +// src/common/validators/validateLatitude.ts +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; + +export const validateLatitude = (value: number | undefined): ReactElement | undefined => { + if (value == null) return undefined; + if (value < -90 || value > 90) { + return ; + } + return undefined; +}; +``` + +## Parameterized Validator (Factory) + +Use a factory function when the validation rule depends on a parameter: + +```tsx +// src/common/validators/validateMaxLength.ts +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; + +export const validateMaxLength = (maxLength: number) => { + return (value: string): ReactElement | undefined => { + if (value && value.length > maxLength) { + return ( + + ); + } + return undefined; + }; +}; +``` + +## Using validator.js for Complex Validation + +For validation rules beyond simple range checks (e.g. email, URL, ISBN, credit card), use the **`validator`** package (validator.js). It is a lightweight, string-only validation library well-suited for frontend form validation. + +### Setup + +Add `validator` and `@types/validator` to your package: + +```bash +npm install validator +npm install -D @types/validator +``` + +### Email Validator + +```tsx +// src/common/validators/validateEmail.tsx +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; +import validator from "validator"; + +export const validateEmail = (value?: string): ReactElement | undefined => { + if (value && !validator.isEmail(value)) { + return ; + } + return undefined; +}; +``` + +### URL Validator + +```tsx +// src/common/validators/validateUrl.tsx +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; +import validator from "validator"; + +export const validateUrl = (value?: string): ReactElement | undefined => { + if (value && !validator.isURL(value)) { + return ; + } + return undefined; +}; +``` + +### Numeric Range Validator (Latitude/Longitude) + +```tsx +// src/common/validators/validateLatitude.tsx +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; +import validator from "validator"; + +export const validateLatitude = (value: number | undefined): ReactElement | undefined => { + if (value == null) return undefined; + if (!validator.isFloat(String(value), { min: -90, max: 90 })) { + return ; + } + return undefined; +}; +``` + +## Array Count Validator + +```tsx +// src/common/validators/validateMaxArrayCount.ts +import { type ReactElement } from "react"; +import { FormattedMessage } from "react-intl"; + +export const validateMaxArrayCount = (maxCount: number) => { + return (value: unknown): ReactElement | undefined => { + if (Array.isArray(value) && value.length > maxCount) { + return ( + + ); + } + return undefined; + }; +}; +``` + +## Usage in Forms + +```tsx +import { validateLatitude } from "@src/common/validators/validateLatitude"; +import { validateMaxLength } from "@src/common/validators/validateMaxLength"; + +// Simple validator — pass directly (stable reference from module scope) + + +// Parameterized validator — call at module scope or in useMemo, NOT inline +const validateTitle = validateMaxLength(120); + + +``` + +## Composing Validators + +To apply multiple validators to a single field, use `composeValidators`: + +```tsx +const composeValidators = (...validators: ((value: any) => ReactElement | undefined)[]) => { + return (value: any): ReactElement | undefined => { + for (const validator of validators) { + const error = validator(value); + if (error) return error; + } + return undefined; + }; +}; + +// Usage: + +``` From 1ec591a62368c935aaeb8465f16fa07c785efe2c Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 13:38:51 +0100 Subject: [PATCH 05/12] add comet-admin-pages skill --- package-skills/comet-admin-pages/SKILL.md | 133 ++++++++++ .../references/01-single-page.md | 41 +++ .../references/02-grid-with-dialog.md | 28 ++ .../references/03-grid-with-edit-page.md | 60 +++++ .../references/04-edit-page-with-tabs.md | 69 +++++ .../references/05-deeply-nested.md | 53 ++++ .../references/06-use-stack-switch.md | 126 +++++++++ .../07-many-to-many-selection-tab.md | 241 ++++++++++++++++++ .../references/toolbar-00-simple.md | 47 ++++ .../references/toolbar-01-entity.md | 191 ++++++++++++++ 10 files changed, 989 insertions(+) create mode 100644 package-skills/comet-admin-pages/SKILL.md create mode 100644 package-skills/comet-admin-pages/references/01-single-page.md create mode 100644 package-skills/comet-admin-pages/references/02-grid-with-dialog.md create mode 100644 package-skills/comet-admin-pages/references/03-grid-with-edit-page.md create mode 100644 package-skills/comet-admin-pages/references/04-edit-page-with-tabs.md create mode 100644 package-skills/comet-admin-pages/references/05-deeply-nested.md create mode 100644 package-skills/comet-admin-pages/references/06-use-stack-switch.md create mode 100644 package-skills/comet-admin-pages/references/07-many-to-many-selection-tab.md create mode 100644 package-skills/comet-admin-pages/references/toolbar-00-simple.md create mode 100644 package-skills/comet-admin-pages/references/toolbar-01-entity.md diff --git a/package-skills/comet-admin-pages/SKILL.md b/package-skills/comet-admin-pages/SKILL.md new file mode 100644 index 0000000000..fd9cfd3e7e --- /dev/null +++ b/package-skills/comet-admin-pages/SKILL.md @@ -0,0 +1,133 @@ +--- +name: comet-admin-pages +description: | + Best practices for building Comet admin page navigation patterns (Stack, StackSwitch, StackPage, StackLink), page layouts (StackToolbar, StackMainContent), RouterTabs, EditDialog, and SaveBoundary placement. + TRIGGER when: user asks to create or modify admin pages, CRUD page structure, navigation patterns, page layouts, or asks about best practices for page structure, toolbars, tabs, or navigation in the Comet admin framework. Also trigger when the user mentions "toolbar", "entity toolbar", or asks to create/modify a toolbar for an entity. Also trigger when any user or agent action involves creating, modifying, or working on admin page components (Stack, StackSwitch, StackPage, RouterTabs, EditDialog, SaveBoundary, StackLink, StackToolbar, StackMainContent). +--- + +# Comet Admin Pages - Navigation Patterns + +Build admin pages by composing navigation (Stack, StackSwitch), layout (StackToolbar, StackMainContent), and tab (RouterTabs) components. Choose the right navigation pattern based on complexity. + +## Decision Guide + +1. **Single page (form or grid)?** -> No StackSwitch needed, just Stack + StackToolbar + StackMainContent +2. **Grid + simple edit?** -> `useEditDialog` (dialog-based, no page navigation) +3. **Grid + complex edit?** -> `StackSwitch` with grid/add/edit `StackPage`s +4. **Edit with sections?** -> `RouterTabs` inside edit `StackPage` +5. **Nested CRUD inside tabs?** -> Nested `StackSwitch` inside `RouterTab` + +## Core Components + +| Component | Purpose | +| --------------------------- | --------------------------------------------------------------------------------------------- | +| `Stack` | Root wrapper, sets top-level breadcrumb title | +| `StackSwitch` | Routes between sibling `StackPage`s (renders one at a time) | +| `StackPage` | Individual page within a `StackSwitch`. Receives URL payload via render function | +| `StackLink` | Router link for stack navigation (preferred over `activatePage`) | +| `StackPageTitle` | Dynamically updates breadcrumb title (used in nested stacks) | +| `StackToolbar` | Toolbar that only renders in the active (deepest) stack | +| `StackMainContent` | Main content area. Use `fullHeight` prop for grids | +| `ToolbarBackButton` | Back navigation in toolbar | +| `ToolbarAutomaticTitleItem` | Auto-displays stack page title in toolbar | +| `ToolbarActions` | Container for action buttons (right side of toolbar) | +| `FillSpace` | Flex spacer between toolbar items | +| `SaveBoundary` | Manages save state, unsaved changes warning. Wraps form pages | +| `SaveBoundarySaveButton` | Save button integrated with SaveBoundary | +| `useEditDialog` | Hook returning `[EditDialog, { id, mode }, editDialogApi]` for dialog-based CRUD | +| `useStackSwitchApi` | Access existing StackSwitch API from child components (e.g., redirect after create in a form) | +| `RouterTabs` / `RouterTab` | URL-based tab navigation. Auto-hides parent tabs when nested | +| `FullHeightContent` | Full viewport height wrapper (for grids inside tabs) | + +## Key Rules + +- **`StackSwitch` must always be wrapped in a `Stack`** component with a `topLevelTitle` prop. The `Stack` sets the top-level breadcrumb and is required for stack navigation to work. Never render `StackSwitch` without a parent `Stack`. +- Use `StackMainContent` (not `MainContent`) inside `Stack`/`StackSwitch` pages. +- Use `StackMainContent fullHeight` when the page contains only a DataGrid. +- Use `FullHeightContent` for DataGrids inside `RouterTab`s. +- Always wrap form pages in `SaveBoundary` with `SaveBoundarySaveButton` in toolbar. +- Always add a `ContentScopeIndicator` to the `StackToolbar` via the `scopeIndicator` prop. Use `` for global/unscoped entities, or `` for scoped entities. Import from `@comet/cms-admin`. +- Prefer `` over `intl.formatMessage()` wherever possible. Only use `intl.formatMessage()` when a prop requires a plain `string` type. Props like `topLevelTitle` and `title` on `Stack`, `StackPage`, `StackSwitch` etc. accept `ReactNode`, so use `` there. +- Use `StackLink` for navigation (not `activatePage`) when possible. For programmatic navigation (e.g., redirect after create), use `useStackSwitchApi()` inside child components (returns `api` to access the nearest parent `StackSwitch`), or `useStackSwitch()` in the page component (returns `[Component, api]`, replaces ``). +- `StackPage name="edit"` uses render function `{(id) => ...}` to receive the URL payload. +- `StackPage name="add"` does not need a render function (no payload). +- `forceRender={true}` on `RouterTab` keeps form state when switching tabs (use for form tabs sharing a `SaveBoundary`). Do NOT use `forceRender` on tabs containing a nested `StackSwitch`. + +## Patterns Reference + +Visual demos of all patterns: [Storybook - Grid and Form Layouts](https://storybook.comet-dxp.com/?path=/docs/docs-best-practices-grid-and-form-layouts--docs) + +Read the relevant pattern file from `references/` based on the use case: + +| Pattern | File | When to use | +| ---------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------ | +| Single Page (grid or form) | [01](references/01-single-page.md) | Page with just a grid or just a form, no sub-navigation | +| Grid with Edit in Dialog | [02](references/02-grid-with-dialog.md) | Simple CRUD, small edit form in dialog | +| Grid with Edit on Page | [03](references/03-grid-with-edit-page.md) | Complex CRUD, full edit page via StackSwitch | +| Grid with Edit Page with RouterTabs | [04](references/04-edit-page-with-tabs.md) | Grid + edit page with tabbed sections (form + grid tabs) | +| Deeply Nested Navigation | [05](references/05-deeply-nested.md) | CRUD inside tabs inside CRUD | +| useStackSwitch (Programmatic Navigation) | [06](references/06-use-stack-switch.md) | Programmatic navigation (e.g., redirect after create) with `useStackSwitch` | +| ManyToMany Selection Tab | [07](references/07-many-to-many-selection-tab.md) | Tab with selected-items grid + modal selection dialog for ManyToMany relations | + +## Dialog Rules + +When using the Comet `Dialog` component (from `@comet/admin`): + +- **Use the `title` prop** — never use MUI `DialogTitle` as a child. Comet's `Dialog` renders its own styled title with close button. +- **Never two primary buttons** — a dialog should have at most one prominent action. Use `variant="textDark"` for the cancel/dismiss button, and the default variant for the confirm action. +- **No `DialogContent` wrapper for DataGrids** — when the dialog body is a DataGrid, place the `DataGridPro` directly inside the `Dialog` (no `DialogContent`). This avoids unwanted padding/margin. Set `sx={{ height: "70vh" }}` on the DataGrid itself. +- **`DialogActions`** from `@mui/material` is fine for the button row at the bottom. + +## Cross-Entity Navigation + +- **`StackLink`** only works within the same `StackSwitch` — it navigates between sibling `StackPage`s. +- **For cross-entity links** (e.g., from a ProductCollection's products tab to the Product edit page), use `RouterLink` from `react-router-dom` with `contentScopeMatch.url` prefix: + ```tsx + import { Link as RouterLink } from "react-router-dom"; + const { match: contentScopeMatch } = useContentScope(); + // ... + + ``` + +## DataGrids + +When a page pattern includes a DataGrid, use the **comet-admin-datagrid** skill to generate it. This skill covers server-side filtering, sorting, pagination, column definitions, and toolbar variants. + +## Toolbar Help Dialog + +`StackToolbar` supports a `topBarActions` prop for placing a `HelpDialogButton` that opens a modal with help information about the current page. Use this when the user requests page-level help or when a page benefits from contextual guidance. + +```tsx +import { HelpDialogButton } from "@comet/admin"; + +} + topBarActions={ + } + dialogDescription={ + + + + } + /> + } +> + + +; +``` + +- `dialogDescription` accepts `ReactNode` — can include images, formatted text, lists, etc. +- Only add when the user explicitly requests it or when a page clearly benefits from contextual help. + +## Toolbar Patterns + +Choose the right toolbar pattern based on complexity: + +| Variant | File | When to use | +| --------------------------- | --------------------------------------------- | ------------------------------------------------------------------------- | +| Simple FormToolbar | [toolbar-00](references/toolbar-00-simple.md) | No entity data needed in toolbar, title from automatic breadcrumbs | +| Entity Toolbar (with Query) | [toolbar-01](references/toolbar-01-entity.md) | Toolbar fetches entity data (title, status), handles loading/error states | + +**Default:** Use the **Entity Toolbar** (toolbar-01) for edit pages. Fall back to the **Simple FormToolbar** (toolbar-00) only for simple pages where the automatic breadcrumb title is sufficient and no entity-specific data is needed in the toolbar. diff --git a/package-skills/comet-admin-pages/references/01-single-page.md b/package-skills/comet-admin-pages/references/01-single-page.md new file mode 100644 index 0000000000..f6822b41ee --- /dev/null +++ b/package-skills/comet-admin-pages/references/01-single-page.md @@ -0,0 +1,41 @@ +# Single Page (Grid or Form) + +No `StackSwitch` needed. These patterns show the content of a single `StackPage` inside a parent `Stack` — just `StackToolbar` + `StackMainContent`. + +## Grid page + +Use `StackMainContent fullHeight` for grids. + +```tsx +<> + }> + + + + + + + +``` + +Use `` for global/unscoped entities, or `` for scoped entities. + +## Form page + +Wrap in `SaveBoundary`. Form component contains its own layout (FieldSet etc.). + +```tsx + + }> + + + + + + + + + + + +``` diff --git a/package-skills/comet-admin-pages/references/02-grid-with-dialog.md b/package-skills/comet-admin-pages/references/02-grid-with-dialog.md new file mode 100644 index 0000000000..a5309b4f6f --- /dev/null +++ b/package-skills/comet-admin-pages/references/02-grid-with-dialog.md @@ -0,0 +1,28 @@ +# Grid with Edit in Dialog + +Use `useEditDialog` for simple CRUD where the edit form is small. No page navigation needed. + +```tsx +const [EditDialog, { id: selectedId, mode }, editDialogApi] = useEditDialog(); + +return ( + <> + }> + + + + + + + : }> + {/* Dialog content with form */} + + +); +``` + +Key points: + +- `editDialogApi.openAddDialog()` to open add dialog (e.g., from a button in DataGridToolbar) +- `editDialogApi.openEditDialog(row.id)` to open edit dialog (e.g., from a row action button) +- No `StackSwitch` or `SaveBoundary` needed — the dialog handles its own save diff --git a/package-skills/comet-admin-pages/references/03-grid-with-edit-page.md b/package-skills/comet-admin-pages/references/03-grid-with-edit-page.md new file mode 100644 index 0000000000..f48d4e5e95 --- /dev/null +++ b/package-skills/comet-admin-pages/references/03-grid-with-edit-page.md @@ -0,0 +1,60 @@ +# Grid with Edit on Page + +Use `StackSwitch` with separate `StackPage`s for grid, add, and edit. Use `StackLink` for navigation. + +```tsx +const FormToolbar = () => ( + }> + + + + + + + +); + +return ( + }> + + + }> + + + + + + + + + + + + + + + + + {(id) => ( + + + + + + + )} + + + +); +``` + +Key points: + +- **`StackSwitch` must be wrapped in a `Stack`** with a `topLevelTitle` — this sets the top-level breadcrumb and is required for the stack navigation to work +- Use `StackLink` for navigation: ` + + ); +} + +// --- Selection Dialog Toolbar --- + +function SelectItemsDialogToolbar() { + return ( + + + + ); +} + +// --- Selection Dialog --- + +function SelectItemsDialog({ + open, + onClose, + onSave, + initialSelection, +}: { + open: boolean; + onClose: () => void; + onSave: (selectedIds: string[]) => void; + initialSelection: string[]; +}) { + const intl = useIntl(); + const { scope } = useContentScope(); + const [selectionModel, setSelectionModel] = useState(initialSelection); + + useEffect(() => { + if (open) { + setSelectionModel(initialSelection); + } + }, [open, initialSelection]); + + const dataGridProps = { + ...useDataGridRemote({ queryParamsPrefix: "selectItems" }), + ...usePersistentColumnState("SelectItemsDialog"), + }; + + const columns: GridColDef[] = useMemo( + () => [ + // data columns only — no actions column + ], + [intl], + ); + + const { filter: gqlFilter, search: gqlSearch } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const { data, loading, error } = useQuery(availableItemsQuery, { + skip: !open, + 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?.items.totalCount); + if (error) throw error; + const rows = data?.items.nodes ?? []; + + return ( + } + > + + + + + + + ); +} + +// --- Main Component --- + +export function MyEntityItemsGrid({ entityId }: { entityId: string }) { + const client = useApolloClient(); + const intl = useIntl(); + const { match: contentScopeMatch } = useContentScope(); + const [dialogOpen, setDialogOpen] = useState(false); + + const { data, loading, error } = useQuery(entityItemsQuery, { + variables: { id: entityId }, + }); + + const selectedItems = useMemo(() => data?.myEntity.items ?? [], [data]); + const selectedItemIds = useMemo(() => selectedItems.map((item) => item.id), [selectedItems]); + + const columns: GridColDef[] = useMemo( + () => [ + // data columns ... + { + field: "actions", + headerName: "", + sortable: false, + filterable: false, + type: "actions", + align: "right", + pinned: "right", + width: 84, + renderCell: (params) => ( + <> + {/* Cross-entity edit link — uses RouterLink, NOT StackLink */} + + + + {/* Remove (unlink) — NOT delete */} + { + const remainingIds = selectedItemIds.filter((id) => id !== params.row.id); + await client.mutate({ + mutation: updateEntityItemsMutation, + variables: { id: entityId, input: { items: remainingIds } }, + refetchQueries: [entityItemsQuery], + }); + }} + > + + + + ), + }, + ], + [intl, client, entityId, selectedItemIds, contentScopeMatch.url], + ); + + if (error) throw error; + + const handleSave = useCallback( + async (newSelectedIds: string[]) => { + await client.mutate({ + mutation: updateEntityItemsMutation, + variables: { id: entityId, input: { items: newSelectedIds } }, + }); + setDialogOpen(false); + }, + [client, entityId], + ); + + return ( + <> + setDialogOpen(true)} />) as GridSlotsComponent["toolbar"], + }} + /> + setDialogOpen(false)} onSave={handleSave} initialSelection={selectedItemIds} /> + + ); +} +``` + +## Rules + +- **Use Comet `Dialog`** (from `@comet/admin`) with the `title` prop — never use MUI `DialogTitle` as a child. +- **Never two primary buttons** in a dialog. Cancel and Save should both use the default Button variant. The page-level save is the primary action. +- **No `DialogContent` wrapper** when the dialog contains a DataGrid — place the `DataGridPro` directly inside the `Dialog` to avoid unwanted padding/margin. Set `sx={{ height: "70vh" }}` on the DataGrid itself. +- **`keepNonExistentRowsSelected: true`** — preserves checkbox selections across pages. +- **`skip: !open`** on the dialog query — don't fetch until the dialog opens. +- **Reset selection on open** — `useEffect` resets `selectionModel` to `initialSelection` when `open` changes. +- **Cross-entity navigation** — use `RouterLink` from `react-router-dom` with `contentScopeMatch.url` prefix, NOT `StackLink` (which only works within the same stack). +- **Remove, not delete** — unlinking an item from a ManyToMany relation uses `RemoveIcon` and filters the ID out of the array. The item itself is not deleted. +- **Select icon** — the toolbar button uses `SelectIcon` from `@comet/admin-icons`. +- **Separate GQL file** — put the queries/mutations in a co-located `.gql.ts` file (e.g., `MyEntityItemsGrid.gql.ts`). diff --git a/package-skills/comet-admin-pages/references/toolbar-00-simple.md b/package-skills/comet-admin-pages/references/toolbar-00-simple.md new file mode 100644 index 0000000000..312dd1a5d6 --- /dev/null +++ b/package-skills/comet-admin-pages/references/toolbar-00-simple.md @@ -0,0 +1,47 @@ +# Simple Form Toolbar + +A lightweight inline toolbar for form pages. No query, no loading/error handling — just layout and save button. + +Use this when the toolbar doesn't need to fetch entity data (e.g. the title comes from automatic breadcrumbs). + +```tsx +const FormToolbar = () => ( + }> + + + + + + + +); +``` + +## Adaptation Rules + +- Use `` for global/unscoped entities, `` for scoped entities. +- Can be defined as a local component in the page file — no separate file needed. +- Reuse the same `FormToolbar` for both add and edit pages within the same `StackSwitch`. + +## Usage in Pages + +```tsx + + {(id) => ( + + + + + + + )} + + + + + + + + + +``` diff --git a/package-skills/comet-admin-pages/references/toolbar-01-entity.md b/package-skills/comet-admin-pages/references/toolbar-01-entity.md new file mode 100644 index 0000000000..ad926f9316 --- /dev/null +++ b/package-skills/comet-admin-pages/references/toolbar-01-entity.md @@ -0,0 +1,191 @@ +# Entity Toolbar (with Query) + +Generate a dedicated toolbar component per entity that fetches its own data (title, status, etc.) and handles loading/error states inline. Each entity gets its own self-contained toolbar in its own file. + +## Files + +Each entity toolbar consists of two files: + +### `{Entity}Toolbar.gql.ts` + +```tsx +import { gql } from "@apollo/client"; + +export const productToolbarQuery = gql` + query ProductToolbar($id: ID!) { + product(id: $id) { + id + name + slug + status + } + } +`; +``` + +Adjust the query name, operation name, and fields to the entity. Include fields needed for the toolbar display (e.g. `name` for title, `status` for chip, a secondary identifier like `slug` or `code` for support text). + +### `{Entity}Toolbar.tsx` + +```tsx +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.slug; + + return ( + + }> + + + {title ? ( + + + {title} + {supportText && ( + + {supportText} + + )} + + + ) : ( + + )} + + {data?.product.status && ( + + + + )} + + {error != null && ( + + + + + + + + + + } + > + + + + + + )} + + + + + {additionalActions} + + + + + ); +}; +``` + +## Title and Support Text + +- When `title` is available (from query data), render it with `` inside a `ToolbarItem`. +- **Support text** should show a meaningful secondary identifier below the title — e.g. a `slug`, `code`, `email`, `sku`, or other human-readable field. Do NOT use the entity `id` (UUID) as support text — it provides no value to the user. If the entity has no meaningful secondary identifier, omit the support text block entirely (remove the `supportText` variable and the `` element). +- When `title` is not available (e.g. on the "add" page where `id` is undefined and query is skipped), fall back to `` which uses the breadcrumb title from `StackPage`. + +## Status Chip + +- When the entity has a status or type enum, display a chip next to the title using the entity's chip component (e.g. ``). +- Render the chip in a separate `` after the title block. +- Only render the chip when data is available (`data?.entity.status && ...`). +- If no chip component exists yet, use the `comet-admin-translatable-enum` skill to create one first. + +## Adaptation Rules + +- Replace `product` / `Product` with the actual entity name throughout (query, types, component name, message IDs). +- Use `` for global/unscoped entities, `` for scoped entities. +- Add entity-specific fields to the query: a meaningful secondary identifier for support text (e.g. `slug`, `code`, `email`) and an enum field for the status chip (e.g. `status`, `type`). +- The `additionalActions` prop allows pages to pass extra buttons (e.g. "Save as Draft") alongside `SaveBoundarySaveButton`. +- For the "add" page (no `id`), the query is skipped and the toolbar renders with `ToolbarAutomaticTitleItem` (no title, no support text). +- Error messages should use entity-specific intl IDs but can share the same default text pattern. +- Do NOT extract a shared Toolbar wrapper — each entity toolbar is self-contained. + +## Usage in Pages + +```tsx + + {(id) => ( + + + + + + + )} + + + + + + + + + +``` From 53eba4ae2a2aa0d1cd673fab6c491610fe1d95e2 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 13:39:07 +0100 Subject: [PATCH 06/12] add comet-crud skill --- package-skills/comet-crud/SKILL.md | 126 +++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 package-skills/comet-crud/SKILL.md diff --git a/package-skills/comet-crud/SKILL.md b/package-skills/comet-crud/SKILL.md new file mode 100644 index 0000000000..fa2e709f0d --- /dev/null +++ b/package-skills/comet-crud/SKILL.md @@ -0,0 +1,126 @@ +--- +name: comet-crud +description: | + Orchestrates full-stack CRUD entity generation for a Comet DXP project by composing existing skills in sequence: + comet-api-graphql → comet-admin-translatable-enum → comet-admin-datagrid → comet-admin-form → comet-admin-pages → MasterMenu. + Each step includes lint/tsc validation and a git commit. + TRIGGER when: user says "create a new entity", "generate CRUD for X", "scaffold X", "full CRUD for X", + or any phrase requesting end-to-end entity generation (API + admin UI) in a Comet DXP project. + Also trigger when a crud sample command (crud-01 through crud-05) is invoked. +--- + +# Comet CRUD Orchestrator + +Compose existing skills to generate a complete CRUD feature (API + Admin) for a Comet DXP entity. + +## Workflow + +### Phase 0 — Plan + +Before generating anything, present a numbered plan to the user. The plan lists each phase below with a short summary of what will be generated. Wait for user confirmation or adjustments before proceeding. + +**Plan template** (adapt to the entity — add/remove rows, adjust descriptions to match the concrete entity): + +``` +I'll generate full CRUD for : + +| # | Phase | What will be generated | Skill | +|-----|--------------------|-------------------------------------------------------------------------------|--------------------------------| +| 1 | API Entity | Entity, service, resolver, DTOs, paginated response, module, migration | comet-api-graphql | +| 2 | Translatable Enums | Per enum: translatable component, chip, form field | comet-admin-translatable-enum | +| 3+4 | DataGrid & Form | Grid + form components (run in parallel via subagents) | comet-admin-datagrid + form | +| 5 | Admin Page | Page component wiring grid + form with Stack/StackSwitch navigation | comet-admin-pages | +| 6 | Master Menu | Route entry in MasterMenu, import page component | — | + +Each phase is validated (lint + tsc) and committed separately. + +Proceed? +``` + +If the entity has no enums, omit the "Translatable Enums" row from the table. + +### Phase 1 — API Entity & GraphQL Layer + +Use the **comet-api-graphql** skill. + +1. Generate entity, service, resolver, all DTOs, module registration, migration, and build API (all handled by the skill) +2. Validate: `npm --prefix api run lint:eslint -- --fix && npm --prefix api run lint:tsc` +3. Commit (see [Commit Strategy](#commit-strategy)) + +### Phase 2 — Translatable Enums + +Skip this phase if the entity has no enum fields. + +Use the **comet-admin-translatable-enum** skill. + +For **every** enum in the entity: + +1. Generate translatable base component +2. Generate chip component (for datagrid columns) +3. Generate form field component (SelectField by default; RadioGroupField if ≤4 options and user prefers) +4. Validate: `npm --prefix admin run lint:eslint -- --fix && npm --prefix admin run lint:tsc` +5. Commit all enum components together + +### Phase 3+4 — Admin DataGrid & Form (parallel) + +Run these two steps **in parallel** using subagents: + +**Subagent A — DataGrid** (comet-admin-datagrid skill): + +1. Generate grid component with columns, toolbar, filters +2. Validate: `npm --prefix admin run lint:eslint -- --fix && npm --prefix admin run lint:tsc` + +**Subagent B — Form** (comet-admin-form skill): + +1. Generate form component with all fields grouped into FieldSets +2. Validate: `npm --prefix admin run lint:eslint -- --fix && npm --prefix admin run lint:tsc` + +After both subagents complete, apply their changes to the main branch and create two separate commits (DataGrid first, then Form) — see [Commit Strategy](#commit-strategy). + +### Phase 5 — Admin Page + +Use the **comet-admin-pages** skill. + +1. Generate page component wiring grid + form with appropriate navigation pattern. +2. Validate: `npm --prefix admin run lint:eslint -- --fix && npm --prefix admin run lint:tsc` +3. Commit (see [Commit Strategy](#commit-strategy)) + +### Phase 6 — Master Menu + +1. Add a route entry to `masterMenuData` in `admin/src/common/MasterMenu.tsx`: + - Import the page component + - Add `type: "route"` entry with `` label, icon, path, component, requiredPermission + - Choose an appropriate icon from `@comet/admin-icons` (ask user if unsure) + - Use kebab-case path (e.g., `/weather-stations`) + - Use camelCase permission string (e.g., `weatherStations`) +2. Validate: `npm --prefix admin run lint:eslint -- --fix && npm --prefix admin run lint:tsc` +3. Commit (see [Commit Strategy](#commit-strategy)) + +## Commit Strategy + +After each phase, create a git commit using `--no-verify` for intermediate phases (1–5), because lint tools like knip may report false positives until all phases are complete and everything is wired together. Only the final phase (Phase 6) commit should run hooks normally (without `--no-verify`). + +1. Stage only the files created/modified in that phase (use `git add ...`) +2. Create commit with a descriptive message, e.g.: + - `Add WeatherStation entity, service, resolver, and DTOs` + - `Add translatable enum components for EpisodeType and ContentRating` + - `Add WeatherStations DataGrid with toolbar` + - `Add WeatherStation edit/create form` + - `Add WeatherStationsPage with Stack navigation` + - `Add WeatherStations to MasterMenu` + +If auto-commit is blocked (e.g., by permission restrictions or hook failures), generate a shell script the user can execute: + +```bash +#!/bin/bash +git add path/to/file1 path/to/file2 +git commit -m "Commit message here" +``` + +Then **wait for the user to confirm** they have run it before proceeding to the next phase. + +## Error Recovery + +- If lint fails: fix the issues automatically, re-run lint, then commit +- If tsc fails: read the errors, fix the source files, re-run tsc, then commit +- If a skill produces unexpected output: read the generated files, compare against the skill's reference docs, and fix From fe72d840f848f16f1572bac860b350cb8c88ab7b Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 13:58:56 +0100 Subject: [PATCH 07/12] Fix skill consistency issues - Fix sort variable to use array in grid-02-row-reordering reference - Change enum field defaults: SelectField (<=4), AutocompleteField (>4), RadioGroupField only on request - Add useAutocompleteOptions helper reference for translatable enum skill - Update enum-04-autocomplete-field to point to helper reference instead of "ask the user" - Add GPG signing fallback hint to comet-crud error recovery --- .../references/grid-02-row-reordering.md | 4 +-- .../references/form-field-06-enum.md | 5 ++-- .../comet-admin-translatable-enum/SKILL.md | 2 ++ .../references/enum-04-autocomplete-field.md | 2 +- .../enum-helper-use-autocomplete-options.md | 28 +++++++++++++++++++ package-skills/comet-crud/SKILL.md | 1 + 6 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 package-skills/comet-admin-translatable-enum/references/enum-helper-use-autocomplete-options.md diff --git a/package-skills/comet-admin-datagrid/references/grid-02-row-reordering.md b/package-skills/comet-admin-datagrid/references/grid-02-row-reordering.md index 34d24af83e..2b3ed7d8e2 100644 --- a/package-skills/comet-admin-datagrid/references/grid-02-row-reordering.md +++ b/package-skills/comet-admin-datagrid/references/grid-02-row-reordering.md @@ -55,7 +55,7 @@ export function EntitiesGrid() { // Query uses fixed sort by position, no filter/search, loads all rows const { data, loading, error } = useQuery(entitiesQuery, { variables: { - sort: { field: "position", direction: "ASC" }, + sort: [{ field: "position", direction: "ASC" }], offset: 0, limit: 100, }, @@ -108,7 +108,7 @@ function EntitiesGridToolbar() { ## Rules - All columns must have `filterable: false` and `sortable: false` -- Query uses fixed `sort: { field: "position", direction: "ASC" }`, `offset: 0`, `limit: 100` +- Query uses fixed `sort: [{ field: "position", direction: "ASC" }]`, `offset: 0`, `limit: 100` - Map rows to include `__reorder__` field (display text during drag, typically `title` or `name`) - Add `rowReordering` and `hideFooterPagination` props to DataGridPro - Toolbar omits `` and `` diff --git a/package-skills/comet-admin-form/references/form-field-06-enum.md b/package-skills/comet-admin-form/references/form-field-06-enum.md index 4e2249781c..9beb3e5b4a 100644 --- a/package-skills/comet-admin-form/references/form-field-06-enum.md +++ b/package-skills/comet-admin-form/references/form-field-06-enum.md @@ -4,8 +4,9 @@ ## Single value -- <=4 options: use `RadioGroupField` -- >4 options: use `SelectField` +- <=4 options: use `SelectField` +- >4 options: use `AutocompleteField` +- `RadioGroupField` only when explicitly requested ```tsx } /> diff --git a/package-skills/comet-admin-translatable-enum/SKILL.md b/package-skills/comet-admin-translatable-enum/SKILL.md index dbe6e584e9..d7106dc215 100644 --- a/package-skills/comet-admin-translatable-enum/SKILL.md +++ b/package-skills/comet-admin-translatable-enum/SKILL.md @@ -48,6 +48,7 @@ Before generating any component, verify these helpers exist. Create from helper | `EnumChip` | Search in project, typically in common/enums area | [enum-helper-enum-chip.md](references/enum-helper-enum-chip.md) | | `ChipIcon` | Search in project (project-specific) | [enum-helper-chip-icon.md](references/enum-helper-chip-icon.md) | | `recordToOptions` | Search in project, typically in common/enums area | [enum-helper-record-to-options.md](references/enum-helper-record-to-options.md) | +| `useAutocompleteOptions` | Search in project, typically in common/enums area | [enum-helper-use-autocomplete-options.md](references/enum-helper-use-autocomplete-options.md) | ### Step 2 — Generate the translatable enum (base component) @@ -91,6 +92,7 @@ Read the relevant reference file based on what the user requests: | `EnumChip` | Generic chip wrapper with dropdown menu | [enum-helper-enum-chip.md](references/enum-helper-enum-chip.md) | | `ChipIcon` | Loading/dropdown state icon for chips | [enum-helper-chip-icon.md](references/enum-helper-chip-icon.md) | | `recordToOptions` | Convert record to options array | [enum-helper-record-to-options.md](references/enum-helper-record-to-options.md) | +| `useAutocompleteOptions` | Hook for autocomplete field options | [enum-helper-use-autocomplete-options.md](references/enum-helper-use-autocomplete-options.md) | ## Auto-detection diff --git a/package-skills/comet-admin-translatable-enum/references/enum-04-autocomplete-field.md b/package-skills/comet-admin-translatable-enum/references/enum-04-autocomplete-field.md index c9cee7f430..14b4540743 100644 --- a/package-skills/comet-admin-translatable-enum/references/enum-04-autocomplete-field.md +++ b/package-skills/comet-admin-translatable-enum/references/enum-04-autocomplete-field.md @@ -5,7 +5,7 @@ Enum selection with search/filter support. Returns `{ value, label: string }`. ## Prerequisites 1. **Translatable enum** must exist — create using [enum-00-translatable.md](enum-00-translatable.md) if missing -2. **`useAutocompleteOptions` hook** must exist — search for it in the project. If missing, ask the user — no reference implementation is bundled +2. **`useAutocompleteOptions` hook** must exist — create using [enum-helper-use-autocomplete-options.md](enum-helper-use-autocomplete-options.md) if missing ## File Location diff --git a/package-skills/comet-admin-translatable-enum/references/enum-helper-use-autocomplete-options.md b/package-skills/comet-admin-translatable-enum/references/enum-helper-use-autocomplete-options.md new file mode 100644 index 0000000000..6b99983523 --- /dev/null +++ b/package-skills/comet-admin-translatable-enum/references/enum-helper-use-autocomplete-options.md @@ -0,0 +1,28 @@ +# Helper: useAutocompleteOptions + +Converts a `MessageDescriptorMap` (from a translatable enum) to an array of `{ value, label }` options for AutocompleteField. Search for it in the project. + +Create this file if it does not exist. + +```ts +import { useIntl, type MessageDescriptor } from "react-intl"; +import { useMemo } from "react"; + +type AutocompleteOption = { + value: T; + label: string; +}; + +export function useAutocompleteOptions(messageDescriptorMap: Record): Array> { + const intl = useIntl(); + + return useMemo( + () => + (Object.entries(messageDescriptorMap) as Array<[T, MessageDescriptor]>).map(([value, descriptor]) => ({ + value, + label: intl.formatMessage(descriptor), + })), + [intl, messageDescriptorMap], + ); +} +``` diff --git a/package-skills/comet-crud/SKILL.md b/package-skills/comet-crud/SKILL.md index fa2e709f0d..4e0e1543ce 100644 --- a/package-skills/comet-crud/SKILL.md +++ b/package-skills/comet-crud/SKILL.md @@ -124,3 +124,4 @@ Then **wait for the user to confirm** they have run it before proceeding to the - If lint fails: fix the issues automatically, re-run lint, then commit - If tsc fails: read the errors, fix the source files, re-run tsc, then commit - If a skill produces unexpected output: read the generated files, compare against the skill's reference docs, and fix +- If committing fails due to GPG signing issues: retry with `-c commit.gpgsign=false` From 16873b0c459c4d007614a9a6068c8b68fbbf8871 Mon Sep 17 00:00:00 2001 From: Manuel Blum Date: Fri, 13 Mar 2026 14:53:20 +0100 Subject: [PATCH 08/12] docs: add docs for skills --- .cspellignore | 1 + .../2-comet-crud/1-comet-api-graphql.md | 88 +++++++++++++++ .../2-comet-admin-translatable-enum.md | 58 ++++++++++ .../2-comet-crud/3-comet-admin-datagrid.md | 88 +++++++++++++++ .../2-comet-crud/4-comet-admin-form.md | 74 ++++++++++++ .../2-comet-crud/5-comet-admin-pages.md | 73 ++++++++++++ .../11-agent-skills/2-comet-crud/index.md | 106 ++++++++++++++++++ docs/docs/11-agent-skills/index.md | 19 ++++ 8 files changed, 507 insertions(+) create mode 100644 docs/docs/11-agent-skills/2-comet-crud/1-comet-api-graphql.md create mode 100644 docs/docs/11-agent-skills/2-comet-crud/2-comet-admin-translatable-enum.md create mode 100644 docs/docs/11-agent-skills/2-comet-crud/3-comet-admin-datagrid.md create mode 100644 docs/docs/11-agent-skills/2-comet-crud/4-comet-admin-form.md create mode 100644 docs/docs/11-agent-skills/2-comet-crud/5-comet-admin-pages.md create mode 100644 docs/docs/11-agent-skills/2-comet-crud/index.md create mode 100644 docs/docs/11-agent-skills/index.md diff --git a/.cspellignore b/.cspellignore index ce5217a10b..c7309fd90d 100644 --- a/.cspellignore +++ b/.cspellignore @@ -53,6 +53,7 @@ retryable mjml autonumber colorpicker +datagrid datetimerangepickerfield datetimerangepicker inlinealert diff --git a/docs/docs/11-agent-skills/2-comet-crud/1-comet-api-graphql.md b/docs/docs/11-agent-skills/2-comet-crud/1-comet-api-graphql.md new file mode 100644 index 0000000000..aafd27ef91 --- /dev/null +++ b/docs/docs/11-agent-skills/2-comet-crud/1-comet-api-graphql.md @@ -0,0 +1,88 @@ +--- +title: comet-api-graphql +sidebar_position: 1 +--- + +The `comet-api-graphql` skill generates the NestJS/GraphQL API layer for a MikroORM entity. It follows the Comet convention of **thin resolvers** (GraphQL layer only) and **fat services** (all business logic). + +## What It Generates + +- **Entity** — MikroORM entity with decorators, validation, and relations +- **Service** — CRUD operations, pagination, filtering, sorting +- **Resolver** — thin GraphQL resolver delegating to the service +- **DTOs** — input, filter, sort, and args classes +- **Paginated Response** — `ObjectType` for paginated list queries +- **Module Registration** — adds the entity, service, and resolver to the NestJS module + +## Key Features + +- Pagination, filtering, and sorting via `@comet/cms-api` utilities +- Content scope support for multi-tenant setups +- Position ordering for sortable entities +- All relation types: `ManyToOne`, `OneToMany`, `ManyToMany`, `OneToOne` +- Slug-based queries with unique validation +- Permission decorators on all resolver methods + +## Examples + +:::tip +Skills should trigger automatically based on your prompt. If a skill does not activate as expected, you can force it by prefixing your prompt with "Use the comet-api-graphql skill" (or `/comet-api-graphql`). +::: + +### Minimal — just fields + +> Create the API for a `BlogPost` entity with title, content, author (string), and publishedAt (date). + +The skill will create a scoped entity with a service, resolver, DTOs, and module registration using sensible defaults. + +### Scoped entity with relations and slug validation + +> Create the API for a `Product` entity scoped by domain + language. +> +> **Fields:** name (string, required), slug (string, required, unique per scope), +> description (string, optional), price (decimal, required), isPublished (boolean, default false), +> mainImage (DAM image, optional). +> +> **Enum** productStatus: Draft, InReview, Published, Archived. +> +> **Relations:** ManyToMany to ProductCategory (owner side). +> +> **Validation:** Slug uniqueness validated server-side — return field-level error `SLUG_ALREADY_EXISTS`. +> Price must be positive. + +### Entity with position ordering and self-reference + +> Create the API for a `ProductCategory` entity scoped by domain + language. +> +> **Fields:** name (string, required), slug (string, required, unique per scope), +> position (number, for manual ordering). +> +> **Relations:** ManyToOne to ProductCategory (optional parent for nesting). + +### Sub-entity scoped via parent relation + +> Create the API for a `ProductVariant` entity scoped via its parent Product relation +> (use `@ScopedEntity` deriving scope from the product). +> +> **Fields:** name (string, required), sku (string, required), price (decimal, required), +> stock (integer, required, default 0), isAvailable (boolean, default true). +> +> **Enum** variantStatus: Active, OutOfStock, Discontinued. +> +> **Relations:** ManyToOne to Product (required parent). +> +> **Validation:** SKU uniqueness within the parent product — return field-level error `SKU_ALREADY_EXISTS`. + +### Unscoped entity with relation + +> Create the API for a `ProductReview` entity (not scoped, global). +> +> **Fields:** title (string, required), body (string, required), rating enum (One through Five), +> reviewerName (string, required), reviewedAt (datetime, required), isApproved (boolean, default false). +> +> **Relations:** ManyToOne to Product (required). + +### Add fields to an existing entity + +> Add a `description` text field and a `ManyToOne` relation to `Department` on the `Employee` entity, +> and update the service, resolver, and DTOs. \ No newline at end of file diff --git a/docs/docs/11-agent-skills/2-comet-crud/2-comet-admin-translatable-enum.md b/docs/docs/11-agent-skills/2-comet-crud/2-comet-admin-translatable-enum.md new file mode 100644 index 0000000000..a72b2378e1 --- /dev/null +++ b/docs/docs/11-agent-skills/2-comet-crud/2-comet-admin-translatable-enum.md @@ -0,0 +1,58 @@ +--- +title: comet-admin-translatable-enum +sidebar_position: 2 +--- + +The `comet-admin-translatable-enum` skill generates React components for displaying and editing GraphQL enums in the Comet admin. It covers translations, colored chips, inline-editable chips, and form field components. + +## What It Generates + +- **Translation component** — maps enum values to translated labels using `react-intl` +- **Chip component** — colored `MuiChip` for displaying enum values (e.g., status badges) +- **Editable chip** — chip with a dropdown menu that triggers an Apollo mutation to change the value inline +- **Form field components** — `SelectField`, `AutocompleteField`, `RadioGroupField`, `CheckboxListField` + +## Key Features + +- All components are fully typed against the generated GraphQL enum +- Chip colors are configurable per enum value +- Editable chips handle optimistic updates and error states +- Form fields integrate with Final Form and support validation + +## Examples + +:::tip +Skills should trigger automatically based on your prompt. If a skill does not activate as expected, you can force it by prefixing your prompt with "Use the comet-admin-translatable-enum skill" (or `/comet-admin-translatable-enum`). +::: + +### All components at once + +> Create all enum components for `productStatus` + +### Translation component + +> Create a translation component for `productStatus` + +### Chip + +> Create a chip for `productStatus` + +### Editable chip + +> Create an editable chip for `productStatus` on the `Product` entity + +### Select field + +> Create a select field for `productStatus` + +### Autocomplete field + +> Create an autocomplete field for `productStatus` + +### Radio group field + +> Create a radio group field for `productStatus` + +### Checkbox list field + +> Create a checkbox list field for `productStatus` \ No newline at end of file diff --git a/docs/docs/11-agent-skills/2-comet-crud/3-comet-admin-datagrid.md b/docs/docs/11-agent-skills/2-comet-crud/3-comet-admin-datagrid.md new file mode 100644 index 0000000000..4d52a347f5 --- /dev/null +++ b/docs/docs/11-agent-skills/2-comet-crud/3-comet-admin-datagrid.md @@ -0,0 +1,88 @@ +--- +title: comet-admin-datagrid +sidebar_position: 3 +--- + +The `comet-admin-datagrid` skill generates server-side MUI DataGrid components for the Comet admin. All filtering, sorting, searching, and pagination are handled server-side using Apollo Client. + +## What It Generates + +- DataGrid component with typed columns +- Server-side pagination, sorting, and filtering via `useDataGridRemote` and `usePaginationQuery` +- Toolbar with search, filter button, and actions (add, delete) +- Filter bar components for each filterable field + +## Key Features + +- Column types: string, boolean, number, date/time, enum (with chips), relations, file uploads +- Multiple variants: standard paginated, non-paginated, row reordering, sub-entity grids +- Selection/checkbox mode for bulk actions +- Excel export support +- Responsive column visibility + +## Examples + +:::tip +Skills should trigger automatically based on your prompt. If a skill does not activate as expected, you can force it by prefixing your prompt with "Use the comet-admin-datagrid skill" (or `/comet-admin-datagrid`). +::: + +### Minimal — no column or filter details + +> Create a datagrid for `BlogPost`. + +The skill reads the entity's GraphQL schema and generates columns, search, and filters based on the available fields. + +### Paginated grid with editable chips, filters, and Excel export + +> Create a datagrid for `Product`. +> +> **Columns:** mainImage (thumbnail, excluded from Excel export), name, sku, productType as editable chip, +> categories (comma-separated names), price, productStatus as editable chip, publishedAt, isPublished. +> +> **Search:** by name and sku. +> +> **Filters:** productStatus, productType. +> +> **Features:** Excel export enabled. + +### Partial specification — only columns, no filters + +> Create a datagrid for `Employee` with columns: name, email, department (relation), hiredAt. + +The skill generates the grid with the specified columns. Since no filters or search are mentioned, it will ask or infer reasonable defaults. + +### Non-paginated grid with row reordering + +> Create a datagrid for `ProductCategory`. +> +> **Columns:** name, slug, parentCategory (show parent name). +> +> **Variant:** Non-paginated grid with drag-and-drop row reordering. +> No search or filters — the dataset is small enough to display in full. + +### Sub-entity grid filtered by parent + +> Create a datagrid for `ProductVariant` as a sub-entity grid of Product. +> +> **Columns:** name, sku, price, stock, variantStatus as editable chip, isAvailable. +> +> **Search:** by name and sku. +> +> The grid is filtered by the parent product ID passed as a prop. + +### Grid with optional relation filter prop + +> Create a datagrid for `ProductReview`. +> +> **Columns:** title, rating, reviewerName, product name, reviewedAt, isApproved. +> +> **Search:** by title and reviewerName. +> +> **Filters:** product (relation filter with autocomplete), isApproved. +> +> **Features:** Excel export. The component accepts an optional `productId` prop +> to filter reviews for a specific product (for reuse on the Product detail page). + +### Add a column to an existing grid + +> Add a `variantCount` column to the `Product` datagrid showing the number of variants per product in a grey Chip. \ No newline at end of file diff --git a/docs/docs/11-agent-skills/2-comet-crud/4-comet-admin-form.md b/docs/docs/11-agent-skills/2-comet-crud/4-comet-admin-form.md new file mode 100644 index 0000000000..551cfe2bd8 --- /dev/null +++ b/docs/docs/11-agent-skills/2-comet-crud/4-comet-admin-form.md @@ -0,0 +1,74 @@ +--- +title: comet-admin-form +sidebar_position: 4 +--- + +The `comet-admin-form` skill generates Final Form components for the Comet admin. All forms support both create and edit modes, save conflict detection, and Apollo Client mutations. + +## What It Generates + +- Form component using `FinalForm` with `editMode` support +- Apollo `useQuery` for loading existing data (edit mode) +- Apollo `useMutation` for create and update operations +- Field layout using `FieldSet` grouping +- Save conflict detection via `SaveConflictDialog` + +## Key Features + +- All `@comet/admin` field components: text, textarea, number, checkbox, switch, select, async autocomplete, date/time pickers +- File upload fields (Dam image/file) +- Block fields (rich text, content blocks) +- Validation with field-level and form-level rules +- Automatic dirty-checking and unsaved changes warning + +## Examples + +:::tip +Skills should trigger automatically based on your prompt. If a skill does not activate as expected, you can force it by prefixing your prompt with "Use the comet-admin-form skill" (or `/comet-admin-form`). +::: + +### Minimal + +> Create a form for `BlogPost`. + +The skill reads the entity's GraphQL schema and generates a form with appropriate field types and a single FieldSet grouping all fields. + +### Form with FieldSets, DAM image, and client-side validation + +> Create a form for `Product`. +> +> **FieldSets:** +> - "General": name (text), slug (text), description (textarea), categories (AsyncAutocompleteField, multiple) +> - "Details": sku (text), price (number), productType (SelectField) +> - "Publishing": productStatus (SelectField), publishedAt (date picker), isPublished (switch) +> - "Media": mainImage (DAM image) +> +> **Validation:** price must be positive (client-side). sku must match format `[A-Z]{2,4}-[0-9]{4,8}` (client-side). + +### Simple form with single FieldSet and relation + +> Create a form for `ProductCategory`. +> +> **FieldSets:** +> - "General": name (text), slug (text), parentCategory (AsyncSelectField) + +### Sub-entity form (parent set implicitly) + +> Create a form for `ProductVariant` (sub-entity of Product — the product field is set implicitly from the parent). +> +> **FieldSets:** +> - "General": name (text), sku (text), variantStatus (SelectField) +> - "Pricing & Stock": price (number), stock (number), isAvailable (switch) +> +> **Validation:** price must be positive, stock must be zero or positive integer (client-side). + +### Dialog-based form + +> Create a form for `ProductReview` rendered inside an EditDialog (no separate page). +> +> **Fields:** product (AsyncAutocompleteField, placed at the top), title (text), body (textarea), +> rating (SelectField), reviewerName (text), reviewedAt (datetime picker), isApproved (checkbox). + +### Add a field to an existing form + +> Add a `notes` textarea field to the `Product` form in the "General" FieldSet. \ No newline at end of file diff --git a/docs/docs/11-agent-skills/2-comet-crud/5-comet-admin-pages.md b/docs/docs/11-agent-skills/2-comet-crud/5-comet-admin-pages.md new file mode 100644 index 0000000000..fd5c6fe356 --- /dev/null +++ b/docs/docs/11-agent-skills/2-comet-crud/5-comet-admin-pages.md @@ -0,0 +1,73 @@ +--- +title: comet-admin-pages +sidebar_position: 5 +--- + +The `comet-admin-pages` skill generates the page structure, navigation, and layout for Comet admin CRUD views. It encodes best practices for Stack-based routing, toolbars, and page composition. + +## What It Generates + +- Page component with `Stack`, `StackSwitch`, and `StackPage` navigation +- `StackToolbar` with entity title and actions +- `StackMainContent` for page body layout +- `SaveBoundary` placement for form pages +- `RouterTabs` for multi-tab edit views +- `EditDialog` for lightweight inline editing + +## Key Features + +- Decision guide: simple page → grid + dialog → grid + page → edit with tabs → nested CRUD +- Entity Toolbar pattern with its own GraphQL query, loading/error states, and title display +- `StackLink` for navigation between list and detail views +- Breadcrumb-style navigation via Stack + +## Examples + +:::tip +Skills should trigger automatically based on your prompt. If a skill does not activate as expected, you can force it by prefixing your prompt with "Use the comet-admin-pages skill" (or `/comet-admin-pages`). +::: + +### Minimal — let the skill decide the layout + +> Create admin pages for `BlogPost`. + +The skill generates a standard grid + edit page layout with StackSwitch and an entity toolbar. + +### Grid with separate edit page and entity toolbar + +> Create admin pages for `Product`. +> +> **Layout:** StackSwitch with grid page, add page, and edit page. +> +> **Grid toolbar:** "Add Product" button navigating to the add page. +> +> **Entity toolbar:** Shows the product name with the SKU as support text. +> Includes a delete button. + +### Edit page with RouterTabs and nested sub-entity + +> Create admin pages for `Product` with an edit page containing RouterTabs: +> +> - Tab "Product": the Product form +> - Tab "Variants": a ProductVariant sub-entity grid with its own nested StackSwitch +> for add/edit variant pages +> - Tab "Reviews": a ProductReview grid filtered by the current product +> +> The variant edit page shows the variant name in the entity toolbar. + +### Grid with dialog-based edit (no separate page) + +> Create admin pages for `ProductReview`. +> +> **Layout:** Single page with the grid and an EditDialog on the same page. +> No StackSwitch needed. The dialog opens for both adding and editing. +> +> Use `ContentScopeIndicator global` since the entity is unscoped. + +### Simple grid with edit page (non-paginated) + +> Create admin pages for `ProductCategory`. +> +> **Layout:** StackSwitch with grid page and edit page. +> +> **Entity toolbar:** Shows the category name. \ No newline at end of file diff --git a/docs/docs/11-agent-skills/2-comet-crud/index.md b/docs/docs/11-agent-skills/2-comet-crud/index.md new file mode 100644 index 0000000000..ee6e831b5b --- /dev/null +++ b/docs/docs/11-agent-skills/2-comet-crud/index.md @@ -0,0 +1,106 @@ +--- +title: comet-crud +sidebar_position: 2 +--- + +The `comet-crud` skill orchestrates all other skills to generate a complete full-stack CRUD entity — from the API layer through to the admin UI — in a single run. Each phase is validated (lint + tsc) and committed separately. + +## What It Does + +1. **API Entity & GraphQL** — generates the MikroORM entity, service, resolver, DTOs, and module registration (via `comet-api-graphql`) +2. **Translatable Enums** — generates translation components and form fields for any enums used by the entity (via `comet-admin-translatable-enum`) +3. **DataGrid & Form** — generates the list view and edit form in parallel (via `comet-admin-datagrid` + `comet-admin-form`) +4. **Admin Pages** — generates the page structure with navigation, toolbars, and routing (via `comet-admin-pages`) +5. **Master Menu** — adds the route entry to the application menu + +## Examples + +:::tip +Skills should trigger automatically based on your prompt. If a skill does not activate as expected, you can force it by prefixing your prompt with "Use the comet-crud skill" (or `/comet-crud`). +::: + +### Minimal — let the skill decide + +> Create CRUD for a `BlogPost` entity with title, content (rich text), author (string), and publishedAt (date). + +The skill will infer sensible defaults: scoped entity, paginated grid with all fields as columns, a single-FieldSet form, and a standard grid + edit page layout. + +### Detailed — scoped entity with enums, relations, and DAM image + +> Create a `Product` entity scoped by domain + language. +> +> **Fields:** name (string, required), slug (string, unique per scope), description (optional, multiline), +> price (decimal, required, must be positive), isPublished (boolean, default false), mainImage (DAM image, optional). +> +> **Enum** productStatus: Draft, InReview, Published, Archived. +> +> **Relations:** ManyToMany to ProductCategory. +> +> **Validation:** Slug uniqueness validated server-side. +> +> **Grid:** Columns: mainImage thumbnail, name, productStatus as editable chip, price, isPublished. +> Search by name, filter by productStatus, Excel export. +> +> **Form:** FieldSets: "General" (name, slug, description, categories), "Details" (price, productStatus), +> "Media" (mainImage). +> +> **Pages:** Grid with edit on separate page. Entity toolbar showing product name. + +### Non-paginated entity with position ordering + +> Create a `ProductCategory` entity scoped by domain + language. +> +> **Fields:** name (string, required), slug (string, unique per scope), position (for manual ordering). +> +> **Relations:** ManyToOne to ProductCategory (optional parent for nesting). +> +> **Grid:** Non-paginated with drag-and-drop row reordering. +> Columns: name, slug, parentCategory name. No search or filters. +> +> **Form:** Single FieldSet: name, slug, parentCategory (AsyncSelectField). +> +> **Pages:** Grid with edit page. Entity toolbar showing category name. + +### Sub-entity managed within a parent + +> Create a `ProductVariant` entity as a sub-entity of Product (scoped via product relation). +> +> **Fields:** name (string, required), sku (string, required, unique within parent product), +> price (decimal, required, positive), stock (integer, required, default 0, non-negative), +> isAvailable (boolean, default true). +> +> **Enum** variantStatus: Active, OutOfStock, Discontinued. +> +> **Relations:** ManyToOne to Product (required parent). +> +> **Validation:** SKU uniqueness validated server-side within the parent product. +> +> **Grid:** Sub-entity grid filtered by parent product ID. +> Columns: name, sku, price, stock, variantStatus as editable chip, isAvailable. +> Search by name and sku. +> +> **Form:** FieldSets: "General" (name, sku, variantStatus select), +> "Pricing & Stock" (price, stock, isAvailable). +> +> **Pages:** Embedded as a "Variants" RouterTab in the Product edit page with nested StackSwitch. +> Entity toolbar showing variant name. + +### Unscoped entity with edit dialog + +> Create a `ProductReview` entity (not scoped, global). +> +> **Fields:** title (string, required), body (multiline, required), rating enum (One through Five), +> reviewerName (string, required), reviewedAt (datetime, required), isApproved (boolean, default false). +> +> **Relations:** ManyToOne to Product (required). +> +> **Grid:** Columns: title, rating, reviewerName, product name, reviewedAt, isApproved. +> Search by title and reviewerName, filter by product (relation filter) and isApproved, Excel export. +> Accepts optional `productId` prop for filtering on the Product detail page. +> +> **Form:** Edit via dialog overlay (no separate edit page). +> Dialog fields: product (AsyncAutocompleteField), title, body, rating (SelectField), +> reviewerName, reviewedAt, isApproved. +> +> **Pages:** Grid with dialog-based edit on the same page. Use `ContentScopeIndicator global`. +> Product edit page includes a "Reviews" RouterTab reusing the same grid with a productId filter. \ No newline at end of file diff --git a/docs/docs/11-agent-skills/index.md b/docs/docs/11-agent-skills/index.md new file mode 100644 index 0000000000..3f157af33d --- /dev/null +++ b/docs/docs/11-agent-skills/index.md @@ -0,0 +1,19 @@ +--- +title: Agent Skills +--- + +Agent Skills are reusable prompt-based instructions that teach AI coding assistants (like Claude Code) how to generate code following Comet DXP conventions. Each skill encodes project-specific patterns, best practices, and code examples so the AI can produce consistent, high-quality output. + + +For installation instructions, see the [Installing agent skills](../4-guides/5-installing-agent-skills.md) guide. + +### Available Skills + +| Skill | Description | +| --- | --- | +| [comet-crud](2-comet-crud/index.md) | Full-stack CRUD orchestrator — chains all skills to scaffold a complete entity | +| [comet-api-graphql](2-comet-crud/1-comet-api-graphql.md) | NestJS/GraphQL API: entity, service, resolver, DTOs, module | +| [comet-admin-translatable-enum](2-comet-crud/2-comet-admin-translatable-enum.md) | Translatable enum components, chips, and form fields | +| [comet-admin-datagrid](2-comet-crud/3-comet-admin-datagrid.md) | Server-side MUI DataGrid with filtering, sorting, and pagination | +| [comet-admin-form](2-comet-crud/4-comet-admin-form.md) | Final Form components with create/edit modes and save conflict detection | +| [comet-admin-pages](2-comet-crud/5-comet-admin-pages.md) | Page navigation, layouts, toolbars, and routing patterns | \ No newline at end of file From 2c0115782b0cffbcebb0bb247cc43213703109a2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:27:26 +0100 Subject: [PATCH 09/12] Fix RadioGroupField props type: use RadioGroupFieldProps instead of SelectFieldProps (#5326) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: manuelblum <6098356+manuelblum@users.noreply.github.com> --- .../references/enum-05-radio-group-field.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/package-skills/comet-admin-translatable-enum/references/enum-05-radio-group-field.md b/package-skills/comet-admin-translatable-enum/references/enum-05-radio-group-field.md index c1c8cf451b..a260d1cee2 100644 --- a/package-skills/comet-admin-translatable-enum/references/enum-05-radio-group-field.md +++ b/package-skills/comet-admin-translatable-enum/references/enum-05-radio-group-field.md @@ -15,10 +15,8 @@ All options visible at once. Best for few options (<=4). ## Template -Same shape as SelectField, swap `SelectField` → `RadioGroupField`: - ```tsx -import { RadioGroupField, type SelectFieldProps } from "@comet/admin"; +import { RadioGroupField, type RadioGroupFieldProps } from "@comet/admin"; import { {camelCaseName}FormattedMessageMap } from "{enumImportPath}"; import { recordToOptions } from "@src/common/components/enums/recordToOptions"; import { type GQL{EnumName} } from "@src/graphql.generated"; @@ -26,7 +24,7 @@ import { type FunctionComponent } from "react"; export type {EnumName}FormState = GQL{EnumName}; -type {EnumName}RadioGroupFieldProps = Omit, "options">; +type {EnumName}RadioGroupFieldProps = Omit, "options">; export const {EnumName}RadioGroupField: FunctionComponent<{EnumName}RadioGroupFieldProps> = ({ name, ...restProps }) => { return ; @@ -38,7 +36,7 @@ export const {EnumName}RadioGroupField: FunctionComponent<{EnumName}RadioGroupFi ```tsx // File: admin/src/location/components/locationStatusRadioGroupField/LocationStatusRadioGroupField.tsx -import { RadioGroupField, type SelectFieldProps } from "@comet/admin"; +import { RadioGroupField, type RadioGroupFieldProps } from "@comet/admin"; import { locationStatusFormattedMessageMap } from "@src/common/components/enums/locationStatus/locationStatus/LocationStatus"; import { recordToOptions } from "@src/common/components/enums/recordToOptions"; import { type GQLLocationStatus } from "@src/graphql.generated"; @@ -46,7 +44,7 @@ import { type FunctionComponent } from "react"; export type LocationStatusFormState = GQLLocationStatus; -type LocationStatusRadioGroupFieldProps = Omit, "options">; +type LocationStatusRadioGroupFieldProps = Omit, "options">; export const LocationStatusRadioGroupField: FunctionComponent = ({ name, ...restProps }) => { return ; From d17078b68b6092b5bd74d4a6a09017b04bf2ddc3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:55:43 +0100 Subject: [PATCH 10/12] Fix CheckboxListField skill template: use CheckboxListFieldProps and array form state (#5324) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: manuelblum <6098356+manuelblum@users.noreply.github.com> --- .../references/enum-06-checkbox-list-field.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/package-skills/comet-admin-translatable-enum/references/enum-06-checkbox-list-field.md b/package-skills/comet-admin-translatable-enum/references/enum-06-checkbox-list-field.md index 132be0009d..675a3a6c1a 100644 --- a/package-skills/comet-admin-translatable-enum/references/enum-06-checkbox-list-field.md +++ b/package-skills/comet-admin-translatable-enum/references/enum-06-checkbox-list-field.md @@ -15,18 +15,16 @@ Multi-select enum field. Form state is an array. ## Template -Same shape as SelectField, swap `SelectField` → `CheckboxListField`: - ```tsx -import { CheckboxListField, type SelectFieldProps } from "@comet/admin"; +import { CheckboxListField, type CheckboxListFieldProps } from "@comet/admin"; import { {camelCaseName}FormattedMessageMap } from "{enumImportPath}"; import { recordToOptions } from "@src/common/components/enums/recordToOptions"; import { type GQL{EnumName} } from "@src/graphql.generated"; import { type FunctionComponent } from "react"; -export type {EnumName}FormState = GQL{EnumName}; +export type {EnumName}FormState = GQL{EnumName}[]; -type {EnumName}CheckboxListFieldProps = Omit, "options">; +type {EnumName}CheckboxListFieldProps = Omit, "options">; export const {EnumName}CheckboxListField: FunctionComponent<{EnumName}CheckboxListFieldProps> = ({ name, ...restProps }) => { return ; @@ -38,15 +36,15 @@ export const {EnumName}CheckboxListField: FunctionComponent<{EnumName}CheckboxLi ```tsx // File: admin/src/location/components/locationStatusCheckboxListField/LocationStatusCheckboxListField.tsx -import { CheckboxListField, type SelectFieldProps } from "@comet/admin"; +import { CheckboxListField, type CheckboxListFieldProps } from "@comet/admin"; import { locationStatusFormattedMessageMap } from "@src/common/components/enums/locationStatus/locationStatus/LocationStatus"; import { recordToOptions } from "@src/common/components/enums/recordToOptions"; import { type GQLLocationStatus } from "@src/graphql.generated"; import { type FunctionComponent } from "react"; -export type LocationStatusFormState = GQLLocationStatus; +export type LocationStatusFormState = GQLLocationStatus[]; -type LocationStatusCheckboxListFieldProps = Omit, "options">; +type LocationStatusCheckboxListFieldProps = Omit, "options">; export const LocationStatusCheckboxListField: FunctionComponent = ({ name, ...restProps }) => { return ; From 7d09517d9fde4e891b45edd7db03e6dfdb296ca2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:04:12 +0100 Subject: [PATCH 11/12] Use RadioGroupFieldProps instead of SelectFieldProps in RadioGroupField skill reference (#5325) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: manuelblum <6098356+manuelblum@users.noreply.github.com> From ab9885b5e670ab892d85da506d33a6aa595f75ae Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:58:12 +0100 Subject: [PATCH 12/12] Fix `onSelectItem` handler in editable chip skill template (#5328) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: manuelblum <6098356+manuelblum@users.noreply.github.com> --- .../references/enum-02-editable-chip.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-skills/comet-admin-translatable-enum/references/enum-02-editable-chip.md b/package-skills/comet-admin-translatable-enum/references/enum-02-editable-chip.md index d286f8a7f4..958b75a12a 100644 --- a/package-skills/comet-admin-translatable-enum/references/enum-02-editable-chip.md +++ b/package-skills/comet-admin-translatable-enum/references/enum-02-editable-chip.md @@ -84,8 +84,8 @@ export const {EnumName}ChipEditableFor{EntityName}: FunctionComponent<{EnumName} <{EnumName}Chip value={data.{entityQuery}.{enumFieldName}} loading={loading || updateLoading} - onSelectItem={({enumFieldName}) => { - updateMutation({ variables: { id: {entityId}, {enumFieldName} } }); + onSelectItem={(value) => { + updateMutation({ variables: { id: {entityId}, {enumFieldName}: value } }); }} /> ) : null;