Skip to content

Commit

Permalink
API Generator: Support position field (#2255)
Browse files Browse the repository at this point in the history
Generate code to correctly update position fields in create, update and
delete mutations.

- position missing for create: add at the end
- position set for create: update all later entities
- position set for update: move others
- entity deleted: remove gap
- position in input is always optional, min(1) and int
- position in input to high: set current max value
- support for position-grouping, always adding scope if existing

---------

Co-authored-by: Johannes Obermair <[email protected]>
  • Loading branch information
Ben-Ho and johnnyomair authored Oct 2, 2024
1 parent 5a48ae4 commit f2da11d
Show file tree
Hide file tree
Showing 13 changed files with 508 additions and 23 deletions.
7 changes: 7 additions & 0 deletions .changeset/dull-windows-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@comet/cms-api": minor
---

API Generator: Add support for position field

Add a field named `position` to enable this feature. This field will hold and update the position. This should be an integer number field >= 1. It's also possible to define fields (in CrudGenerator-Decorator) to group position by.
4 changes: 4 additions & 0 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ type ProductCategory {
id: ID!
title: String!
slug: String!
position: Int!
createdAt: DateTime!
updatedAt: DateTime!
products: [Product!]!
Expand Down Expand Up @@ -1066,6 +1067,7 @@ input ProductCategorySort {
enum ProductCategorySortField {
title
slug
position
createdAt
updatedAt
}
Expand Down Expand Up @@ -1455,11 +1457,13 @@ input ProductUpdateInput {
input ProductCategoryInput {
title: String!
slug: String!
position: Int
}

input ProductCategoryUpdateInput {
title: String
slug: String
position: Int
}

input ProductTagInput {
Expand Down
9 changes: 9 additions & 0 deletions demo/api/src/db/migrations/Migration20240723114541.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Migration } from '@mikro-orm/migrations';

export class Migration20240723114541 extends Migration {

async up(): Promise<void> {
this.addSql('delete from "ProductCategory";');
this.addSql('alter table "ProductCategory" add column "position" integer not null;');
}
}
8 changes: 7 additions & 1 deletion demo/api/src/products/entities/product-category.entity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CrudField, CrudGenerator } from "@comet/cms-api";
import { BaseEntity, Collection, Entity, OneToMany, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { Field, ID, ObjectType } from "@nestjs/graphql";
import { Field, ID, Int, ObjectType } from "@nestjs/graphql";
import { Min } from "class-validator";
import { v4 as uuid } from "uuid";

import { Product } from "./product.entity";
Expand All @@ -23,6 +24,11 @@ export class ProductCategory extends BaseEntity<ProductCategory, "id"> {
@Field()
slug: string;

@Property({ columnType: "integer" })
@Field(() => Int)
@Min(1)
position: number;

@CrudField({
resolveField: true, //default is true
//search: true, //not implemented
Expand Down
10 changes: 8 additions & 2 deletions demo/api/src/products/generated/dto/product-category.input.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// This file has been generated by comet api-generator.
// You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment.
import { IsSlug, PartialType } from "@comet/cms-api";
import { Field, InputType } from "@nestjs/graphql";
import { IsNotEmpty, IsString } from "class-validator";
import { Field, InputType, Int } from "@nestjs/graphql";
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator";

@InputType()
export class ProductCategoryInput {
Expand All @@ -16,6 +16,12 @@ export class ProductCategoryInput {
@IsSlug()
@Field()
slug: string;

@IsOptional()
@Min(1)
@IsInt()
@Field(() => Int, { nullable: true })
position?: number;
}

@InputType()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IsEnum } from "class-validator";
export enum ProductCategorySortField {
title = "title",
slug = "slug",
position = "position",
createdAt = "createdAt",
updatedAt = "updatedAt",
}
Expand Down
35 changes: 35 additions & 0 deletions demo/api/src/products/generated/product-categories.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// This file has been generated by comet api-generator.
// You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment.
import { InjectRepository } from "@mikro-orm/nestjs";
import { EntityManager, EntityRepository } from "@mikro-orm/postgresql";
import { Injectable } from "@nestjs/common";

import { ProductCategory } from "../entities/product-category.entity";

@Injectable()
export class ProductCategoriesService {
constructor(
private readonly entityManager: EntityManager,
@InjectRepository(ProductCategory) private readonly repository: EntityRepository<ProductCategory>,
) {}

async incrementPositions(lowestPosition: number, highestPosition?: number) {
// Increment positions between newPosition (inclusive) and oldPosition (exclusive)
await this.repository.nativeUpdate(
{ position: { $gte: lowestPosition, ...(highestPosition ? { $lt: highestPosition } : {}) } },
{ position: this.entityManager.raw("position + 1") },
);
}

async decrementPositions(lowestPosition: number, highestPosition?: number) {
// Decrement positions between oldPosition (exclusive) and newPosition (inclusive)
await this.repository.nativeUpdate(
{ position: { $gt: lowestPosition, ...(highestPosition ? { $lte: highestPosition } : {}) } },
{ position: this.entityManager.raw("position - 1") },
);
}

async getLastPosition() {
return this.repository.count({});
}
}
24 changes: 24 additions & 0 deletions demo/api/src/products/generated/product-category.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import { ProductCategory } from "../entities/product-category.entity";
import { PaginatedProductCategories } from "./dto/paginated-product-categories";
import { ProductCategoriesArgs } from "./dto/product-categories.args";
import { ProductCategoryInput, ProductCategoryUpdateInput } from "./dto/product-category.input";
import { ProductCategoriesService } from "./product-categories.service";

@Resolver(() => ProductCategory)
@RequiredPermission(["products"], { skipScopeCheck: true })
export class ProductCategoryResolver {
constructor(
private readonly entityManager: EntityManager,
private readonly productCategoriesService: ProductCategoriesService,
@InjectRepository(ProductCategory) private readonly repository: EntityRepository<ProductCategory>,
) {}

Expand Down Expand Up @@ -65,8 +67,17 @@ export class ProductCategoryResolver {

@Mutation(() => ProductCategory)
async createProductCategory(@Args("input", { type: () => ProductCategoryInput }) input: ProductCategoryInput): Promise<ProductCategory> {
const lastPosition = await this.productCategoriesService.getLastPosition();
let position = input.position;
if (position !== undefined && position < lastPosition + 1) {
await this.productCategoriesService.incrementPositions(position);
} else {
position = lastPosition + 1;
}

const productCategory = this.repository.create({
...input,
position,
});

await this.entityManager.flush();
Expand All @@ -82,6 +93,18 @@ export class ProductCategoryResolver {
): Promise<ProductCategory> {
const productCategory = await this.repository.findOneOrFail(id);

if (input.position !== undefined) {
const lastPosition = await this.productCategoriesService.getLastPosition();
if (input.position > lastPosition + 1) {
input.position = lastPosition + 1;
}
if (productCategory.position < input.position) {
await this.productCategoriesService.decrementPositions(productCategory.position, input.position);
} else if (productCategory.position > input.position) {
await this.productCategoriesService.incrementPositions(input.position, productCategory.position);
}
}

productCategory.assign({
...input,
});
Expand All @@ -96,6 +119,7 @@ export class ProductCategoryResolver {
async deleteProductCategory(@Args("id", { type: () => ID }) id: string): Promise<boolean> {
const productCategory = await this.repository.findOneOrFail(id);
this.entityManager.remove(productCategory);
await this.productCategoriesService.decrementPositions(productCategory.position);
await this.entityManager.flush();
return true;
}
Expand Down
2 changes: 2 additions & 0 deletions demo/api/src/products/products.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ProductVariant } from "./entities/product-variant.entity";
import { ManufacturerResolver } from "./generated/manufacturer.resolver";
import { ManufacturerCountryResolver } from "./generated/manufacturer-country.resolver";
import { ProductResolver } from "./generated/product.resolver";
import { ProductCategoriesService } from "./generated/product-categories.service";
import { ProductCategoryResolver } from "./generated/product-category.resolver";
import { ProductTagResolver } from "./generated/product-tag.resolver";
import { ProductToTagResolver } from "./generated/product-to-tag.resolver";
Expand All @@ -37,6 +38,7 @@ import { ProductVariantResolver } from "./generated/product-variant.resolver";
providers: [
ProductResolver,
ProductCategoryResolver,
ProductCategoriesService,
ProductTagResolver,
ProductVariantResolver,
ManufacturerResolver,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface CrudGeneratorOptions {
update?: boolean;
delete?: boolean;
list?: boolean;
position?: { groupByFields: string[] };
}

export function CrudGenerator({
Expand All @@ -14,12 +15,13 @@ export function CrudGenerator({
update = true,
delete: deleteMutation = true,
list = true,
position,
}: CrudGeneratorOptions): ClassDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Function) {
Reflect.defineMetadata(
`data:crudGeneratorOptions`,
{ targetDirectory, requiredPermission, create, update, delete: deleteMutation, list },
{ targetDirectory, requiredPermission, create, update, delete: deleteMutation, list, position },
target,
);
};
Expand Down
29 changes: 22 additions & 7 deletions packages/api/cms-api/src/generator/generate-crud-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function generateCrudInput(
): Promise<GeneratedFile[]> {
const generatedFiles: GeneratedFile[] = [];

const { dedicatedResolverArgProps } = buildOptions(metadata);
const { dedicatedResolverArgProps } = buildOptions(metadata, generatorOptions);

const props = metadata.props
.filter((prop) => {
Expand All @@ -70,14 +70,29 @@ export async function generateCrudInput(
const fieldName = prop.name;
const definedDecorators = morphTsProperty(prop.name, metadata).getDecorators();
const decorators = [] as Array<string>;
if (!prop.nullable) {
decorators.push("@IsNotEmpty()");
} else {
decorators.push("@IsNullable()");
let isOptional = prop.nullable;

if (prop.name != "position") {
if (!prop.nullable) {
decorators.push("@IsNotEmpty()");
} else {
decorators.push("@IsNullable()");
}
}
if (["id", "createdAt", "updatedAt", "scope"].includes(prop.name)) {
//skip those (TODO find a non-magic solution?)
continue;
} else if (prop.name == "position") {
const initializer = morphTsProperty(prop.name, metadata).getInitializer()?.getText();
const defaultValue = initializer == "undefined" || initializer == "null" ? "null" : initializer;
const fieldOptions = tsCodeRecordToString({ nullable: "true", defaultValue });
isOptional = true;
decorators.push(`@IsOptional()`);
decorators.push(`@Min(1)`);
decorators.push("@IsInt()");
decorators.push(`@Field(() => Int, ${fieldOptions})`);

type = "number";
} else if (prop.enum) {
const initializer = morphTsProperty(prop.name, metadata).getInitializer()?.getText();
const defaultValue =
Expand Down Expand Up @@ -390,14 +405,14 @@ export async function generateCrudInput(
}

fieldsOut += `${decorators.join("\n")}
${fieldName}${prop.nullable ? "?" : ""}: ${type};
${fieldName}${isOptional ? "?" : ""}: ${type};
`;
}
const className = options.className ?? `${metadata.className}Input`;
const inputOut = `import { Field, InputType, ID, Int } from "@nestjs/graphql";
import { Transform, Type } from "class-transformer";
import { IsString, IsNotEmpty, ValidateNested, IsNumber, IsBoolean, IsDate, IsOptional, IsEnum, IsUUID, IsArray, IsInt } from "class-validator";
import { IsString, IsNotEmpty, ValidateNested, IsNumber, IsBoolean, IsDate, IsOptional, IsEnum, IsUUID, IsArray, IsInt, Min } from "class-validator";
import { IsSlug, RootBlockInputScalar, IsNullable, PartialType} from "@comet/cms-api";
import { GraphQLJSONObject } from "graphql-scalars";
import { BlockInputInterface, isBlockInputInterface } from "@comet/blocks-api";
Expand Down
Loading

0 comments on commit f2da11d

Please sign in to comment.