diff --git a/.changeset/mighty-hotels-chew.md b/.changeset/mighty-hotels-chew.md new file mode 100644 index 00000000..573846ef --- /dev/null +++ b/.changeset/mighty-hotels-chew.md @@ -0,0 +1,6 @@ +--- +"@example/erp": patch +"@genseki/react": patch +--- + +Feat: Reorder relation field diff --git a/examples/erp/genseki/collections/posts.tsx b/examples/erp/genseki/collections/posts.tsx index b8f04818..2e792327 100644 --- a/examples/erp/genseki/collections/posts.tsx +++ b/examples/erp/genseki/collections/posts.tsx @@ -113,27 +113,33 @@ export const fields = builder.fields('post', (fb) => ({ hidden: true, description: 'The date the post was created', }), - postTags: fb.relations('postTags', (fb) => ({ - type: 'create' as const, - label: 'Tags', - fields: fb.fields('postTag', (fb) => ({ - remark: fb.columns('remark', { - type: 'text', - label: 'Remark', - }), - tag: fb.relations('tag', (fb) => ({ - type: 'connect' as const, - deselectable: false, - fields: fb.fields('tag', (fb) => ({ - name: fb.columns('name', { - type: 'text', - label: 'Name', - }), + postTags: fb.relations( + 'postTags', + (fb) => ({ + type: 'create' as const, + label: 'Tags', + options: 'postTags', + fields: fb.fields('postTag', (fb) => ({ + remark: fb.columns('remark', { + type: 'text', + label: 'Remark', + }), + tag: fb.relations('tag', (fb) => ({ + type: 'connect' as const, + fields: fb.fields('tag', (fb) => ({ + name: fb.columns('name', { + type: 'text', + label: 'Name', + }), + })), + options: 'tag', })), - options: 'tag', })), - })), - })), + }), + { + orderColumn: 'order', + } + ), postDetail: fb.relations('detail', (fb) => ({ type: 'create' as const, label: 'Detail', @@ -205,6 +211,13 @@ export const options = builder.options(fields, { options: tags.map((tag) => ({ label: tag.name, value: tag.id })), } }, + postTags: async () => { + const postTags = await prisma.postTag.findMany() + return { + disabled: false, + options: postTags.map((pt) => ({ label: pt.id, value: pt.id })), + } + }, }) export const postsCollection = createPlugin('posts', (app) => { diff --git a/examples/erp/package.json b/examples/erp/package.json index e74f43ce..847367e4 100644 --- a/examples/erp/package.json +++ b/examples/erp/package.json @@ -34,6 +34,7 @@ "@tiptap/extension-text-style": "^2.14.0", "@tiptap/extension-underline": "^2.14.0", "@tiptap/starter-kit": "^2.14.0", + "lexorank": "^1.0.5", "next": "15.2.2", "next-themes": "^0.4.6", "postcss": "^8.5.3", diff --git a/examples/erp/prisma/migrations/20250918120027_add_order_to_post_tag/migration.sql b/examples/erp/prisma/migrations/20250918120027_add_order_to_post_tag/migration.sql new file mode 100644 index 00000000..4e18b42d --- /dev/null +++ b/examples/erp/prisma/migrations/20250918120027_add_order_to_post_tag/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `order` to the `PostTag` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "PostTag" ADD COLUMN "order" TEXT NOT NULL; diff --git a/examples/erp/prisma/schema.prisma b/examples/erp/prisma/schema.prisma index 595a6d4c..a27a1325 100644 --- a/examples/erp/prisma/schema.prisma +++ b/examples/erp/prisma/schema.prisma @@ -169,6 +169,8 @@ model PostTag { postId String post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + order String + createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt } diff --git a/packages/react/package.json b/packages/react/package.json index 2993a6ae..3c373ac6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -69,6 +69,7 @@ "deepmerge-ts": "^7.1.5", "defu": "^6.1.4", "input-otp": "^1.4.2", + "lexorank": "^1.0.5", "motion": "^12.7.4", "nuqs": "^2.4.3", "radix3": "^1.1.2", diff --git a/packages/react/src/core/__mocks__/sanitized.ts b/packages/react/src/core/__mocks__/sanitized.ts index 36ee9601..d514c166 100644 --- a/packages/react/src/core/__mocks__/sanitized.ts +++ b/packages/react/src/core/__mocks__/sanitized.ts @@ -78,6 +78,39 @@ export const UserModel = model( hasDefaultValue: true, dataType: DataType.BOOLEAN, }, + phone: { + schema: SchemaType.COLUMN, + name: 'phone', + isId: false, + isList: false, + isUnique: true, + isReadOnly: false, + isRequired: false, + hasDefaultValue: false, + dataType: DataType.STRING, + }, + phoneVerified: { + schema: SchemaType.COLUMN, + name: 'phoneVerified', + isId: false, + isList: false, + isUnique: false, + isReadOnly: false, + isRequired: false, + hasDefaultValue: true, + dataType: DataType.BOOLEAN, + }, + staffInfoId: { + schema: SchemaType.COLUMN, + name: 'staffInfoId', + isId: false, + isList: false, + isUnique: false, + isReadOnly: true, + isRequired: false, + hasDefaultValue: false, + dataType: DataType.STRING, + }, roles: { schema: SchemaType.COLUMN, name: 'roles', @@ -155,17 +188,6 @@ export const UserModel = model( hasDefaultValue: true, dataType: DataType.DATETIME, }, - staffInfoId: { - schema: SchemaType.COLUMN, - name: 'staffInfoId', - isId: false, - isList: false, - isUnique: false, - isReadOnly: true, - isRequired: false, - hasDefaultValue: false, - dataType: DataType.STRING, - }, }, relations: { posts: { @@ -198,21 +220,6 @@ export const UserModel = model( relationFromFields: [], relationDataTypes: [DataType.STRING], }, - comments: { - schema: SchemaType.RELATION, - name: 'comments', - isId: false, - isList: true, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: false, - relationName: 'CommentToUser', - referencedModel: 'comment', - relationToFields: [], - relationFromFields: [], - relationDataTypes: [DataType.STRING], - }, accounts: { schema: SchemaType.RELATION, name: 'accounts', @@ -267,7 +274,7 @@ export const UserModel = model( isReadOnly: false, isRequired: false, hasDefaultValue: false, - relationName: 'StaffInfoToUser', + relationName: 'UserTostaffInfo', referencedModel: 'staffInfo', relationToFields: ['id'], relationFromFields: ['staffInfoId'], @@ -275,7 +282,7 @@ export const UserModel = model( }, }, primaryFields: ['id'], - uniqueFields: [['id'], ['email']], + uniqueFields: [['id'], ['email'], ['phone']], }, { name: 'UserModel', @@ -878,21 +885,6 @@ export const PostModel = model( relationFromFields: ['authorId'], relationDataTypes: [DataType.STRING], }, - comments: { - schema: SchemaType.RELATION, - name: 'comments', - isId: false, - isList: true, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: false, - relationName: 'CommentToPost', - referencedModel: 'comment', - relationToFields: [], - relationFromFields: [], - relationDataTypes: [DataType.STRING], - }, tags: { schema: SchemaType.RELATION, name: 'tags', @@ -940,7 +932,18 @@ export const TagModel = model( name: 'name', isId: false, isList: false, - isUnique: true, + isUnique: false, + isReadOnly: false, + isRequired: true, + hasDefaultValue: false, + dataType: DataType.STRING, + }, + order: { + schema: SchemaType.COLUMN, + name: 'order', + isId: false, + isList: false, + isUnique: false, isReadOnly: false, isRequired: true, hasDefaultValue: false, @@ -998,7 +1001,7 @@ export const TagModel = model( }, }, primaryFields: ['id'], - uniqueFields: [['id'], ['name']], + uniqueFields: [['id']], }, { name: 'TagModel', @@ -1009,121 +1012,7 @@ export const TagModel = model( export type TagModel = Simplify -export const CommentModel = model( - { - columns: { - id: { - schema: SchemaType.COLUMN, - name: 'id', - isId: true, - isList: false, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: true, - dataType: DataType.STRING, - }, - content: { - schema: SchemaType.COLUMN, - name: 'content', - isId: false, - isList: false, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: false, - dataType: DataType.STRING, - }, - postId: { - schema: SchemaType.COLUMN, - name: 'postId', - isId: false, - isList: false, - isUnique: false, - isReadOnly: true, - isRequired: true, - hasDefaultValue: false, - dataType: DataType.STRING, - }, - authorId: { - schema: SchemaType.COLUMN, - name: 'authorId', - isId: false, - isList: false, - isUnique: false, - isReadOnly: true, - isRequired: true, - hasDefaultValue: false, - dataType: DataType.STRING, - }, - createdAt: { - schema: SchemaType.COLUMN, - name: 'createdAt', - isId: false, - isList: false, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: true, - dataType: DataType.DATETIME, - }, - updatedAt: { - schema: SchemaType.COLUMN, - name: 'updatedAt', - isId: false, - isList: false, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: true, - dataType: DataType.DATETIME, - }, - }, - relations: { - post: { - schema: SchemaType.RELATION, - name: 'post', - isId: false, - isList: false, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: false, - relationName: 'CommentToPost', - referencedModel: 'post', - relationToFields: ['id'], - relationFromFields: ['postId'], - relationDataTypes: [DataType.STRING], - }, - author: { - schema: SchemaType.RELATION, - name: 'author', - isId: false, - isList: false, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: false, - relationName: 'CommentToUser', - referencedModel: 'user', - relationToFields: ['id'], - relationFromFields: ['authorId'], - relationDataTypes: [DataType.STRING], - }, - }, - primaryFields: ['id'], - uniqueFields: [['id']], - }, - { - name: 'CommentModel', - dbModelName: 'Comment', - prismaModelName: 'comment', - } -) - -export type CommentModel = Simplify - -export const StaffInfoModel = model( +export const staffInfoModel = model( { columns: { id: { @@ -1144,7 +1033,7 @@ export const StaffInfoModel = model( isList: false, isUnique: false, isReadOnly: false, - isRequired: false, + isRequired: true, hasDefaultValue: false, dataType: DataType.STRING, }, @@ -1155,32 +1044,10 @@ export const StaffInfoModel = model( isList: false, isUnique: false, isReadOnly: false, - isRequired: false, + isRequired: true, hasDefaultValue: false, dataType: DataType.STRING, }, - createdAt: { - schema: SchemaType.COLUMN, - name: 'createdAt', - isId: false, - isList: false, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: true, - dataType: DataType.DATETIME, - }, - updatedAt: { - schema: SchemaType.COLUMN, - name: 'updatedAt', - isId: false, - isList: false, - isUnique: false, - isReadOnly: false, - isRequired: true, - hasDefaultValue: true, - dataType: DataType.DATETIME, - }, }, relations: { User: { @@ -1192,7 +1059,7 @@ export const StaffInfoModel = model( isReadOnly: false, isRequired: true, hasDefaultValue: false, - relationName: 'StaffInfoToUser', + relationName: 'UserTostaffInfo', referencedModel: 'user', relationToFields: [], relationFromFields: [], @@ -1203,13 +1070,13 @@ export const StaffInfoModel = model( uniqueFields: [['id']], }, { - name: 'StaffInfoModel', - dbModelName: 'StaffInfo', + name: 'staffInfoModel', + dbModelName: 'staffInfo', prismaModelName: 'staffInfo', } ) -export type StaffInfoModel = Simplify +export type staffInfoModel = Simplify export const SanitizedFullModelSchemas = { user: UserModel, @@ -1219,8 +1086,7 @@ export const SanitizedFullModelSchemas = { profile: ProfileModel, post: PostModel, tag: TagModel, - comment: CommentModel, - staffInfo: StaffInfoModel, + staffInfo: staffInfoModel, } export type SanitizedFullModelSchemas = Simplify diff --git a/packages/react/src/core/__mocks__/unsanitized.ts b/packages/react/src/core/__mocks__/unsanitized.ts index ac427c4d..33013471 100644 --- a/packages/react/src/core/__mocks__/unsanitized.ts +++ b/packages/react/src/core/__mocks__/unsanitized.ts @@ -65,6 +65,39 @@ export interface UserModelShape { hasDefaultValue: true dataType: typeof DataType.BOOLEAN } + phone: { + schema: typeof SchemaType.COLUMN + name: 'phone' + isId: false + isList: false + isUnique: true + isReadOnly: false + isRequired: false + hasDefaultValue: false + dataType: typeof DataType.STRING + } + phoneVerified: { + schema: typeof SchemaType.COLUMN + name: 'phoneVerified' + isId: false + isList: false + isUnique: false + isReadOnly: false + isRequired: false + hasDefaultValue: true + dataType: typeof DataType.BOOLEAN + } + staffInfoId: { + schema: typeof SchemaType.COLUMN + name: 'staffInfoId' + isId: false + isList: false + isUnique: false + isReadOnly: true + isRequired: false + hasDefaultValue: false + dataType: typeof DataType.STRING + } roles: { schema: typeof SchemaType.COLUMN name: 'roles' @@ -142,17 +175,6 @@ export interface UserModelShape { hasDefaultValue: true dataType: typeof DataType.DATETIME } - staffInfoId: { - schema: typeof SchemaType.COLUMN - name: 'staffInfoId' - isId: false - isList: false - isUnique: false - isReadOnly: true - isRequired: false - hasDefaultValue: false - dataType: typeof DataType.STRING - } } relations: { posts: { @@ -185,21 +207,6 @@ export interface UserModelShape { relationFromFields: [] relationDataTypes: [typeof DataType.STRING] } - comments: { - schema: typeof SchemaType.RELATION - name: 'comments' - isId: false - isList: true - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: false - relationName: 'CommentToUser' - referencedModel: CommentModel - relationToFields: [] - relationFromFields: [] - relationDataTypes: [typeof DataType.STRING] - } accounts: { schema: typeof SchemaType.RELATION name: 'accounts' @@ -254,15 +261,15 @@ export interface UserModelShape { isReadOnly: false isRequired: false hasDefaultValue: false - relationName: 'StaffInfoToUser' - referencedModel: StaffInfoModel + relationName: 'UserTostaffInfo' + referencedModel: staffInfoModel relationToFields: ['id'] relationFromFields: ['id'] relationDataTypes: [typeof DataType.STRING] } } primaryFields: ['id'] - uniqueFields: [['id'], ['email']] + uniqueFields: [['id'], ['email'], ['phone']] } export interface UserModel { @@ -875,21 +882,6 @@ export interface PostModelShape { relationFromFields: ['id'] relationDataTypes: [typeof DataType.STRING] } - comments: { - schema: typeof SchemaType.RELATION - name: 'comments' - isId: false - isList: true - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: false - relationName: 'CommentToPost' - referencedModel: CommentModel - relationToFields: [] - relationFromFields: [] - relationDataTypes: [typeof DataType.STRING] - } tags: { schema: typeof SchemaType.RELATION name: 'tags' @@ -939,7 +931,18 @@ export interface TagModelShape { name: 'name' isId: false isList: false - isUnique: true + isUnique: false + isReadOnly: false + isRequired: true + hasDefaultValue: false + dataType: typeof DataType.STRING + } + order: { + schema: typeof SchemaType.COLUMN + name: 'order' + isId: false + isList: false + isUnique: false isReadOnly: false isRequired: true hasDefaultValue: false @@ -997,7 +1000,7 @@ export interface TagModelShape { } } primaryFields: ['id'] - uniqueFields: [['id'], ['name']] + uniqueFields: [['id']] } export interface TagModel { @@ -1005,129 +1008,13 @@ export interface TagModel { config: TagModelConfig } -export interface CommentModelConfig { - name: 'CommentModel' - dbModelName: 'Comment' - prismaModelName: 'comment' -} - -export interface CommentModelShape { - columns: { - id: { - schema: typeof SchemaType.COLUMN - name: 'id' - isId: true - isList: false - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: true - dataType: typeof DataType.STRING - } - content: { - schema: typeof SchemaType.COLUMN - name: 'content' - isId: false - isList: false - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: false - dataType: typeof DataType.STRING - } - postId: { - schema: typeof SchemaType.COLUMN - name: 'postId' - isId: false - isList: false - isUnique: false - isReadOnly: true - isRequired: true - hasDefaultValue: false - dataType: typeof DataType.STRING - } - authorId: { - schema: typeof SchemaType.COLUMN - name: 'authorId' - isId: false - isList: false - isUnique: false - isReadOnly: true - isRequired: true - hasDefaultValue: false - dataType: typeof DataType.STRING - } - createdAt: { - schema: typeof SchemaType.COLUMN - name: 'createdAt' - isId: false - isList: false - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: true - dataType: typeof DataType.DATETIME - } - updatedAt: { - schema: typeof SchemaType.COLUMN - name: 'updatedAt' - isId: false - isList: false - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: true - dataType: typeof DataType.DATETIME - } - } - relations: { - post: { - schema: typeof SchemaType.RELATION - name: 'post' - isId: false - isList: false - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: false - relationName: 'CommentToPost' - referencedModel: PostModel - relationToFields: ['id'] - relationFromFields: ['id'] - relationDataTypes: [typeof DataType.STRING] - } - author: { - schema: typeof SchemaType.RELATION - name: 'author' - isId: false - isList: false - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: false - relationName: 'CommentToUser' - referencedModel: UserModel - relationToFields: ['id'] - relationFromFields: ['id'] - relationDataTypes: [typeof DataType.STRING] - } - } - primaryFields: ['id'] - uniqueFields: [['id']] -} - -export interface CommentModel { - shape: CommentModelShape - config: CommentModelConfig -} - -export interface StaffInfoModelConfig { - name: 'StaffInfoModel' - dbModelName: 'StaffInfo' +export interface staffInfoModelConfig { + name: 'staffInfoModel' + dbModelName: 'staffInfo' prismaModelName: 'staffInfo' } -export interface StaffInfoModelShape { +export interface staffInfoModelShape { columns: { id: { schema: typeof SchemaType.COLUMN @@ -1147,7 +1034,7 @@ export interface StaffInfoModelShape { isList: false isUnique: false isReadOnly: false - isRequired: false + isRequired: true hasDefaultValue: false dataType: typeof DataType.STRING } @@ -1158,32 +1045,10 @@ export interface StaffInfoModelShape { isList: false isUnique: false isReadOnly: false - isRequired: false + isRequired: true hasDefaultValue: false dataType: typeof DataType.STRING } - createdAt: { - schema: typeof SchemaType.COLUMN - name: 'createdAt' - isId: false - isList: false - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: true - dataType: typeof DataType.DATETIME - } - updatedAt: { - schema: typeof SchemaType.COLUMN - name: 'updatedAt' - isId: false - isList: false - isUnique: false - isReadOnly: false - isRequired: true - hasDefaultValue: true - dataType: typeof DataType.DATETIME - } } relations: { User: { @@ -1195,7 +1060,7 @@ export interface StaffInfoModelShape { isReadOnly: false isRequired: true hasDefaultValue: false - relationName: 'StaffInfoToUser' + relationName: 'UserTostaffInfo' referencedModel: UserModel relationToFields: [] relationFromFields: [] @@ -1206,9 +1071,9 @@ export interface StaffInfoModelShape { uniqueFields: [['id']] } -export interface StaffInfoModel { - shape: StaffInfoModelShape - config: StaffInfoModelConfig +export interface staffInfoModel { + shape: staffInfoModelShape + config: staffInfoModelConfig } export type FullModelSchemas = { @@ -1219,8 +1084,7 @@ export type FullModelSchemas = { profile: ProfileModel post: PostModel tag: TagModel - comment: CommentModel - staffInfo: StaffInfoModel + staffInfo: staffInfoModel } & {} export const FullModelSchemas = unsanitizedModelSchemas(SanitizedFullModelSchemas) diff --git a/packages/react/src/core/builder.tsx b/packages/react/src/core/builder.tsx index cb31c2a7..760f44ba 100644 --- a/packages/react/src/core/builder.tsx +++ b/packages/react/src/core/builder.tsx @@ -19,7 +19,7 @@ export class Builder( modelName: TModelName, configFn: (fb: FieldBuilder) => TFieldsShape, - info?: { identifierColumn?: string } + info?: { identifierColumn?: string; orderColumn?: string } ) { const fb = new FieldBuilder({ context: this.config.context, diff --git a/packages/react/src/core/builder.utils.ts b/packages/react/src/core/builder.utils.ts index c15517f4..06df1d0f 100644 --- a/packages/react/src/core/builder.utils.ts +++ b/packages/react/src/core/builder.utils.ts @@ -25,6 +25,7 @@ import type { PrismaOrderByCondition, PrismaSearchCondition } from './prisma.typ import { transformFieldPayloadToPrismaCreatePayload, transformFieldPayloadToPrismaUpdatePayload, + transformFieldPayloadToUpdateOrderPayload, transformFieldsToPrismaSelectPayload, transformPrismaResultToFieldsPayload, } from './transformer' @@ -52,8 +53,24 @@ function createCollectionDefaultCreateHandler { const transformedData = transformFieldPayloadToPrismaCreatePayload(args.fields, args.data) - const result = await prisma[model.config.prismaModelName].create({ - data: transformedData, + const updateTransformedData = transformFieldPayloadToUpdateOrderPayload(args.fields, args.data) + const result = await prisma.$transaction(async (tx) => { + const result = tx[model.config.prismaModelName].create({ + data: transformedData, + }) + Object.entries(updateTransformedData).map(async ([key, value]) => { + if (Array.isArray(value)) { + await Promise.all( + value.map(async (item) => { + await tx[key].update(item) + }) + ) + } else { + await tx[key].update(value) + } + }) + + return result }) const __id = result[fields.config.identifierColumn] diff --git a/packages/react/src/core/collection/index.tsx b/packages/react/src/core/collection/index.tsx index 6ae074d5..c96a770b 100644 --- a/packages/react/src/core/collection/index.tsx +++ b/packages/react/src/core/collection/index.tsx @@ -160,11 +160,14 @@ export type InferCreateOneRelationFieldShape< create: TFieldShape extends FieldRelationCreateShape | FieldRelationConnectOrCreateShape ? _InferCreateFields : never + + __order?: string } : {}) & ('connect' extends TKeys ? { connect: InferDataType + __order?: string } : {}) & ('disconnect' extends TKeys diff --git a/packages/react/src/core/field.ts b/packages/react/src/core/field.ts index 921d504d..c35e088f 100644 --- a/packages/react/src/core/field.ts +++ b/packages/react/src/core/field.ts @@ -36,6 +36,9 @@ export interface FieldRelationClientMetadata { } export interface FieldRelationMetadata extends Omit { relation: FieldRelationSchema + config: { + orderColumn?: string + } } export interface FieldOptionsShapeBase { @@ -567,7 +570,8 @@ export class FieldBuilder< TModelSchemas, TModelSchemas[TModelName]['shape']['relations'][TFieldRelationName]['referencedModel']['config']['prismaModelName'] > - ) => TOptions + ) => TOptions, + config?: { orderColumn?: string } ) { const relation = this.model.shape.relations[ fieldRelationName as string @@ -599,6 +603,7 @@ export class FieldBuilder< // This field will be mutated by the builder to include the field name fieldName: '', relation: relation, + config: config || {}, }, } satisfies FieldRelationShapeBase diff --git a/packages/react/src/core/transformer/index.spec.ts b/packages/react/src/core/transformer/index.spec.ts index 70cca31e..15b90663 100644 --- a/packages/react/src/core/transformer/index.spec.ts +++ b/packages/react/src/core/transformer/index.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { transformFieldPayloadToPrismaCreatePayload, transformFieldPayloadToPrismaUpdatePayload, + transformFieldPayloadToUpdateOrderPayload, transformFieldsToPrismaSelectPayload, transformPrismaResultToFieldsPayload, } from '.' @@ -124,6 +125,117 @@ const userFieldsOptional = builder.fields('user', (fb) => ({ })), })) +const postFields = builder.fields('post', (fb) => ({ + title: fb.columns('title', { + type: 'text', + }), + content: fb.columns('content', { + type: 'richText', + editor: {}, + }), + published: fb.columns('published', { + type: 'checkbox', + }), + user: fb.relations('author', (fb) => ({ + type: 'connect', + options: 'user', + fields: fb.fields('user', (fb) => ({ + name: fb.columns('name', { + type: 'text', + }), + })), + })), + tags: fb.relations( + 'tags', + (fb) => ({ + type: 'connect', + options: 'tag', + fields: fb.fields('tag', (fb) => ({ + name: fb.columns('name', { + type: 'text', + }), + })), + }), + { + orderColumn: 'order', + } + ), +})) + +const postCreateFields = builder.fields('post', (fb) => ({ + title: fb.columns('title', { + type: 'text', + }), + content: fb.columns('content', { + type: 'richText', + editor: {}, + }), + published: fb.columns('published', { + type: 'checkbox', + }), + user: fb.relations('author', (fb) => ({ + type: 'connect', + options: 'user', + fields: fb.fields('user', (fb) => ({ + name: fb.columns('name', { + type: 'text', + }), + })), + })), + tags: fb.relations( + 'tags', + (fb) => ({ + type: 'create', + options: 'tag', + fields: fb.fields('tag', (fb) => ({ + name: fb.columns('name', { + type: 'text', + }), + })), + }), + { + orderColumn: 'order', + } + ), +})) + +const postConnectOrCreateFields = builder.fields('post', (fb) => ({ + title: fb.columns('title', { + type: 'text', + }), + content: fb.columns('content', { + type: 'richText', + editor: {}, + }), + published: fb.columns('published', { + type: 'checkbox', + }), + user: fb.relations('author', (fb) => ({ + type: 'connect', + options: 'user', + fields: fb.fields('user', (fb) => ({ + name: fb.columns('name', { + type: 'text', + }), + })), + })), + tags: fb.relations( + 'tags', + (fb) => ({ + type: 'connectOrCreate', + options: 'tag', + fields: fb.fields('tag', (fb) => ({ + name: fb.columns('name', { + type: 'text', + }), + })), + }), + { + orderColumn: 'order', + } + ), +})) + describe('transformer', () => { describe('transformToPrismaCreatePayload', () => { it('should transform payload correctly', () => { @@ -334,6 +446,117 @@ describe('transformer', () => { const output = transformFieldPayloadToPrismaCreatePayload(userFieldsOptional, input) expect(output).toEqual(expected) }) + + it('should transform payload to update order payload correctly', () => { + const input = { + user: { + connect: 'user-id-1', + }, + title: 'Post 1', + content: {}, + published: true, + tags: [ + { connect: 'tag-id-1', __order: '12314' }, + { connect: 'tag-id-1', __order: '12313' }, + { connect: 'tag-id-1', __order: '12315' }, + ], + } satisfies InferCreateFields + + const expected = { + tag: [ + { where: { id: 'tag-id-1' }, data: { order: '12314' } }, + { where: { id: 'tag-id-1' }, data: { order: '12313' } }, + { where: { id: 'tag-id-1' }, data: { order: '12315' } }, + ], + } + + const output = transformFieldPayloadToUpdateOrderPayload(postFields, input) + expect(output).toEqual(expected) + }) + + it('should handle empty tags array in update order payload', () => { + const input = { + user: { + connect: 'user-id-1', + }, + title: 'Post 1', + content: {}, + published: true, + tags: [], + } satisfies InferCreateFields + + const expected = { + tag: [], + } + + const output = transformFieldPayloadToUpdateOrderPayload(postFields, input) + expect(output).toEqual(expected) + }) + + it('should return empty array when tags only contain create in update order payload', () => { + const input = { + user: { + connect: 'user-id-1', + }, + title: 'Post 1', + content: {}, + published: true, + tags: [ + { + __order: '12314', + create: { + name: 'Tag 1', + }, + }, + { + __order: '12313', + create: { + name: 'Tag 2', + }, + }, + ], + } satisfies InferCreateFields + + const expected = {} + + const output = transformFieldPayloadToUpdateOrderPayload(postCreateFields, input) + expect(output).toEqual(expected) + }) + + it('should only include connect items in update order payload when both create and connect are present', () => { + const input = { + user: { + connect: 'user-id-1', + }, + title: 'Post 1', + content: {}, + published: true, + tags: [ + { + __order: '12314', + create: { + name: 'Tag 1', + }, + }, + { + __order: '12313', + connect: 'tag-id-1', + }, + ], + } satisfies InferCreateFields + + const expected = { + tag: [ + { + where: { id: 'tag-id-1' }, + data: { order: '12313' }, + }, + ], + } + + const output = transformFieldPayloadToUpdateOrderPayload(postConnectOrCreateFields, input) + expect(output).toEqual(expected) + }) }) describe('transformToPrismaUpdatePayload', () => { @@ -590,6 +813,7 @@ describe('transformer', () => { const output = transformFieldsToPrismaSelectPayload(userFields) expect(output).toEqual(expected) }) + it('should transform fields to Prisma select payload (with optional (roles is optional))', () => { const expected = { id: true, @@ -626,6 +850,24 @@ describe('transformer', () => { const output = transformFieldsToPrismaSelectPayload(userFieldsOptional) expect(output).toEqual(expected) }) + + it('should transform post fields to Prisma select payload with order column', () => { + const expected = { + id: true, + title: true, + content: true, + published: true, + author: { select: { id: true, name: true } }, + tags: { + orderBy: { + order: 'asc', + }, + select: { id: true, name: true, order: true }, + }, + } + const output = transformFieldsToPrismaSelectPayload(postFields) + expect(output).toEqual(expected) + }) }) describe('transformPrismaResultToFieldsPayload', () => { @@ -855,5 +1097,69 @@ describe('transformer', () => { const output = transformPrismaResultToFieldsPayload(userFieldsOptional, payload) expect(output).toEqual(expected) }) + it('should transform post prisma result to fields payload with order column', () => { + const payload = { + id: 'mock-post-id', + title: 'Hello World', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { textAlign: 'left', level: 1 }, + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + published: true, + author: { + id: 'mock-user-id', + name: 'John Doe', + }, + tags: [ + { id: 'mock-tag-id', name: 'Tag 1', order: '12314' }, + { id: 'mock-tag-id-2', name: 'Tag 2', order: '12315' }, + ], + } + + const expected = { + __pk: 'mock-post-id', + __id: 'mock-post-id', + title: 'Hello World', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { textAlign: 'left', level: 1 }, + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + published: true, + user: { + __id: 'mock-user-id', + __pk: 'mock-user-id', + name: 'John Doe', + }, + tags: [ + { + __id: 'mock-tag-id', + __pk: 'mock-tag-id', + name: 'Tag 1', + __order: '12314', + }, + { + __id: 'mock-tag-id-2', + __pk: 'mock-tag-id-2', + name: 'Tag 2', + __order: '12315', + }, + ], + } + + const output = transformPrismaResultToFieldsPayload(postFields, payload) + expect(output).toEqual(expected) + }) }) }) diff --git a/packages/react/src/core/transformer/index.ts b/packages/react/src/core/transformer/index.ts index f9dcc827..799fcd15 100644 --- a/packages/react/src/core/transformer/index.ts +++ b/packages/react/src/core/transformer/index.ts @@ -2,6 +2,61 @@ import type { FieldRelationShape, Fields } from '../field' import { DataType } from '../model' import { isRelationFieldShape } from '../utils' +export function transformFieldPayloadToUpdateOrderPayload( + fields: Fields, + payload: any +): Record { + return Object.entries(fields.shape).reduce((acc, [fieldKey, fieldShape]) => { + const inputValue = payload[fieldKey] + if (inputValue === undefined) return acc + + if (isRelationFieldShape(fieldShape) && fieldShape.$server.config.orderColumn) { + const prismaModelName = fieldShape.$server.relation.referencedModel.config.prismaModelName + const orderColumn = fieldShape.$server.config.orderColumn + const isList = fieldShape.$server.relation.isList + switch (fieldShape.type) { + case 'connect': + return { + ...acc, + [prismaModelName]: isList + ? (inputValue as any[]).map((v: any) => ({ + where: { id: v.connect }, + data: { [orderColumn]: v.__order }, + })) + : [ + { + where: { id: inputValue.connect }, + data: { [orderColumn]: inputValue.__order }, + }, + ], + } + case 'connectOrCreate': + return { + ...acc, + [prismaModelName]: isList + ? (inputValue as any[]).reduce((acc, v) => { + if (v.connect) { + return [ + ...acc, + { + where: { id: v.connect }, + data: { [orderColumn]: v.__order }, + }, + ] + } + return acc + }, []) + : [], + } + default: + break + } + } + + return acc + }, {}) +} + export function transformFieldPayloadToPrismaCreatePayload( fields: Fields, payload: any @@ -46,13 +101,22 @@ function transformFieldRelationPayloadToPrismaCreatePayload( throw new Error(`Field "${fieldShape.$server.fieldName}" is not a relation field`) } + const orderColumn = fieldShape.$server.config.orderColumn + switch (fieldShape.type) { case 'create': { return { create: fieldShape.$server.relation.isList - ? (payload as any[]).map((v) => - transformFieldPayloadToPrismaCreatePayload(fieldShape.fields, v.create) - ) + ? (payload as any[]).map((v) => { + const transformData = transformFieldPayloadToPrismaCreatePayload( + fieldShape.fields, + v.create + ) + if (orderColumn && v.__order !== undefined) { + transformData[orderColumn] = v.__order + } + return transformData + }) : transformFieldPayloadToPrismaCreatePayload(fieldShape.fields, payload.create), } } @@ -251,8 +315,15 @@ export function transformFieldsToPrismaSelectPayload(fields: Fields) { if (fieldShape.$server.source === 'column') { acc[fieldShape.$server.column.name] = true } else if (isRelationFieldShape(fieldShape)) { - acc[fieldShape.$server.relation.name] = { - select: transformFieldsToPrismaSelectPayload(fieldShape.fields), + const orderColumn = fieldShape.$server.config.orderColumn + const selectPayload = transformFieldsToPrismaSelectPayload(fieldShape.fields) + const relationName = fieldShape.$server.relation.name + acc[relationName] = { + select: selectPayload, + } + if (orderColumn) { + acc[relationName].select[orderColumn] = true + acc[relationName].orderBy = { [orderColumn]: 'asc' } } } return acc @@ -290,17 +361,27 @@ export function transformPrismaResultToFieldsPayload( } if (isRelationFieldShape(fieldShape)) { + const orderColumn = fieldShape.$server.config.orderColumn if (fieldShape.$server.relation.isList) { if (!Array.isArray(value)) { throw new Error( `Expected an array for relation field "${fieldShape.$server.fieldName}", but got ${typeof value}` ) } - value = (value as any[]).map((v) => - transformPrismaResultToFieldsPayload(fieldShape.fields, v) - ) + value = (value as any[]).map((v) => { + const transformed = transformPrismaResultToFieldsPayload(fieldShape.fields, v) + if (orderColumn && v[orderColumn] !== undefined) { + return { ...transformed, __order: v[orderColumn] } + } + return transformed + }) } else { - value = transformPrismaResultToFieldsPayload(fieldShape.fields, value) + const transformed = transformPrismaResultToFieldsPayload(fieldShape.fields, value) + if (orderColumn && value[orderColumn] !== undefined) { + value = { ...transformed, __order: value[orderColumn] } + } else { + value = transformed + } } } diff --git a/packages/react/src/react/components/compound/auto-field/index.tsx b/packages/react/src/react/components/compound/auto-field/index.tsx index a245e94a..77cefe45 100644 --- a/packages/react/src/react/components/compound/auto-field/index.tsx +++ b/packages/react/src/react/components/compound/auto-field/index.tsx @@ -6,6 +6,7 @@ import { useFieldArray, useFormContext, useWatch } from 'react-hook-form' import { EnvelopeIcon } from '@phosphor-icons/react' import { useQuery } from '@tanstack/react-query' import type { Content } from '@tiptap/react' +import { LexoRank } from 'lexorank' import { BaseIcon, @@ -19,6 +20,7 @@ import { Label, NumberField, type NumberFieldProps, + ReorderGroup, RichTextEditor, Select, SelectLabel, @@ -671,7 +673,7 @@ interface AutoManyRelationshipFieldProps { } export function AutoManyRelationshipField(props: AutoManyRelationshipFieldProps) { - const { control } = useFormContext() + const { control, watch } = useFormContext() const fieldArray = useFieldArray({ control: control, name: props.name, @@ -723,27 +725,75 @@ export function AutoManyRelationshipField(props: AutoManyRelationshipFieldProps) )) } + // Reusable onMove handler for reordering with LexoRank + const handleReorderMove = (oldIndex: number, newIndex: number) => { + const field = fieldArray.fields[oldIndex] as any + const newFields = [...fieldArray.fields] + newFields.splice(oldIndex, 1) + newFields.splice(newIndex, 0, field) + + const prevField = newIndex > 0 ? (newFields[newIndex - 1] as any) : undefined + const nextField = newIndex < newFields.length - 1 ? (newFields[newIndex + 1] as any) : undefined + + let newLexoRank: LexoRank + if (!prevField && nextField?.__order) { + newLexoRank = LexoRank.parse(nextField.__order).genPrev() + } else if (prevField?.__order && !nextField) { + newLexoRank = LexoRank.parse(prevField.__order).genNext() + } else if (prevField?.__order && nextField?.__order) { + newLexoRank = LexoRank.parse(prevField.__order).between(LexoRank.parse(nextField.__order)) + } else { + newLexoRank = LexoRank.middle() + } + + fieldArray.update(oldIndex, { + ...field, + __order: newLexoRank.toString(), + }) + fieldArray.move(oldIndex, newIndex) + } + + const nextOrder = () => { + const lastField = fieldArray.fields.at(-1) as { __order?: string } | undefined + const lastOrder = lastField?.__order ?? LexoRank.middle().toString() + const nextOrder = LexoRank.parse(lastOrder).genNext().toString() + return nextOrder + } + switch (fieldShape.type) { case 'connect': return (
{!!fieldArray.fields.length && (
- {fieldArray.fields.map((fieldValue, index) => ( -
-
- {fieldShape.label} #{index + 1} + fieldArray.remove(index)} + > + {fieldArray.fields.map((fieldValue, index) => ( +
+ {connectComponent(`${props.name}.${index}.connect`, fieldShape.options)}
- {connectComponent(`${props.name}.${index}.connect`, fieldShape.options)} -
- ))} + ))} +
)} @@ -785,22 +837,33 @@ export function AutoManyRelationshipField(props: AutoManyRelationshipFieldProps) case 'connectOrCreate': return ( -
- {fieldArray.fields.map((fieldValue, index) => ( -
-
- {fieldShape.label} #{index + 1} +
+ fieldArray.remove(index)} + > + {fieldArray.fields.map((fieldValue, index) => ( +
+ {connectComponent(`${props.name}.${index}.connect`, fieldShape.options)} + {createComponent(`${props.name}.${index}.create`)}
- {connectComponent(`${props.name}.${index}.connect`, fieldShape.options)} - {createComponent(`${props.name}.${index}.create`)} -
- ))} + ))} + diff --git a/packages/react/src/react/components/compound/reorder-group/index.tsx b/packages/react/src/react/components/compound/reorder-group/index.tsx index 74d431f4..993945e5 100644 --- a/packages/react/src/react/components/compound/reorder-group/index.tsx +++ b/packages/react/src/react/components/compound/reorder-group/index.tsx @@ -14,13 +14,17 @@ import { import { CSS } from '@dnd-kit/utilities' import { IconChevronLgDown } from '@intentui/icons' import { DotsSixIcon } from '@phosphor-icons/react' +import { TrashIcon } from '@phosphor-icons/react/dist/ssr' import { BaseIcon, Button, Typography } from '../../primitives' interface ReorderGroupProps { title?: string children: ReactNode + collapsible?: boolean onReorder?: (newOrder: string[]) => void + onMove?: (oldIndex: number, newIndex: number) => void + onDelete?: (index: number) => void } interface SortableItemProps { @@ -28,9 +32,18 @@ interface SortableItemProps { children: ReactElement title?: string order?: number + collapsible?: boolean + onDelete?: (index: number) => void } -const ReorderGroup = ({ children, onReorder, title }: ReorderGroupProps) => { +const ReorderGroup = ({ + children, + onReorder, + onMove, + title, + collapsible = true, + onDelete, +}: ReorderGroupProps) => { // convert children to array of ReactElement const elements = Children.toArray(children) as ReactElement[] @@ -67,6 +80,7 @@ const ReorderGroup = ({ children, onReorder, title }: ReorderGroupProps) => { // update state setOrder(newOrder) onReorder?.(newOrder) + onMove?.(oldIndex, newIndex) } return ( @@ -76,7 +90,14 @@ const ReorderGroup = ({ children, onReorder, title }: ReorderGroupProps) => { const element = elementMap.get(id) if (!element) return null return ( - + {element} ) @@ -86,7 +107,7 @@ const ReorderGroup = ({ children, onReorder, title }: ReorderGroupProps) => { ) } -const SortableItem = ({ id, children, title, order }: SortableItemProps) => { +const SortableItem = ({ id, children, title, order, collapsible, onDelete }: SortableItemProps) => { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }) const style = { @@ -119,14 +140,27 @@ const SortableItem = ({ id, children, title, order }: SortableItemProps) => {
- + {collapsible && ( + + )} + {onDelete && ( + + )}
{isShowContent && children} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c9b5e23..81a8249c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@tiptap/starter-kit': specifier: ^2.14.0 version: 2.14.0 + lexorank: + specifier: ^1.0.5 + version: 1.0.5 next: specifier: 15.2.2 version: 15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -288,7 +291,7 @@ importers: version: 15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 2.4.3(next@15.2.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) radix3: specifier: ^1.1.2 version: 1.1.2 @@ -502,12 +505,15 @@ importers: input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + lexorank: + specifier: ^1.0.5 + version: 1.0.5 motion: specifier: ^12.7.4 version: 12.11.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 2.4.3(next@15.2.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) radix3: specifier: ^1.1.2 version: 1.1.2 @@ -3884,6 +3890,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lexorank@1.0.5: + resolution: {integrity: sha512-K1B/Yr/gIU0wm68hk/yB0p/mv6xM3ShD5aci42vOwcjof8slG8Kpo3Q7+1WTv7DaRHKWRgLPqrFDt+4GtuFAtA==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -9619,6 +9628,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lexorank@1.0.5: {} + lightningcss-darwin-arm64@1.30.1: optional: true @@ -9829,7 +9840,7 @@ snapshots: node-releases@2.0.19: {} - nuqs@2.4.3(next@15.2.2(@babel/core@7.27.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + nuqs@2.4.3(next@15.2.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: mitt: 3.0.1 react: 19.1.0