From 0acd7b6dd38a9e451764ddf9959bb0cd1d14aa9d Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Sat, 18 Nov 2023 22:02:21 +0800 Subject: [PATCH] fix: sort by association collection (#3058) * fix: sort by association collection * fix: test * chore: test * chore: create sortfield if hasmany sortable set true * fix: drag sort * chore: update sort by options * chore: sync collection after update sortBy * chore: test * chore: test * chore: test * fix: sort by * fix: model.syncSortByField * fix: hasmany only * fix: sortBy --------- Co-authored-by: chenos --- .../actions/src/__tests__/move-action.test.ts | 156 +++++++++++++++++- packages/core/actions/src/actions/list.ts | 1 + packages/core/actions/src/actions/move.ts | 56 +++++-- .../src/block-provider/TableBlockProvider.tsx | 4 +- .../antd/table-v2/TableBlockDesigner.tsx | 16 +- .../database/src/fields/has-many-field.ts | 14 ++ .../core/database/src/fields/sort-field.ts | 7 +- .../__tests__/collections.repository.test.ts | 18 ++ .../server/__tests__/fields/hasMany.test.ts | 53 ++++++ .../__tests__/http-api/has-many.test.ts | 60 +++++++ .../src/server/__tests__/index.ts | 1 - .../src/server/hooks/beforeInitOptions.ts | 3 + .../src/server/models/collection.ts | 1 + .../src/server/models/field.ts | 14 ++ .../src/server/server.ts | 12 +- 15 files changed, 385 insertions(+), 31 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/http-api/has-many.test.ts diff --git a/packages/core/actions/src/__tests__/move-action.test.ts b/packages/core/actions/src/__tests__/move-action.test.ts index 2a276ab74..ae5023de7 100644 --- a/packages/core/actions/src/__tests__/move-action.test.ts +++ b/packages/core/actions/src/__tests__/move-action.test.ts @@ -1,9 +1,157 @@ import { mockServer, MockServer } from './index'; import { registerActions } from '@nocobase/actions'; -import { Database } from '@nocobase/database'; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +import { Collection, Database } from '@nocobase/database'; +import { waitSecond } from '@nocobase/test'; describe('sort action', () => { + describe('associations', () => { + let api: MockServer; + + let UserCollection: Collection; + + beforeEach(async () => { + api = mockServer(); + + registerActions(api); + + UserCollection = api.db.collection({ + name: 'users', + fields: [ + { type: 'string', name: 'name' }, + { + type: 'hasMany', + name: 'posts', + }, + + { type: 'sort', name: 'sort' }, + ], + }); + + api.db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'sort', name: 'sort' }, + { type: 'belongsTo', name: 'user' }, + ], + }); + + await api.db.sync(); + + for (let index = 1; index < 5; index++) { + await UserCollection.repository.create({ + values: { + name: `u${index}`, + posts: [ + { + title: `u${index}p1`, + }, + { + title: `u${index}p2`, + }, + { + title: `u${index}p3`, + }, + ], + }, + }); + } + }); + + afterEach(async () => { + return api.destroy(); + }); + + it('should not move association items when association not sortable', async () => { + const u1 = await api.db.getRepository('users').findOne({ + filter: { + name: 'u1', + }, + }); + + const response = await api.agent().resource('users.posts', u1.get('id')).move({ + sourceId: 1, + targetId: 3, + }); + + expect(response.status).not.toEqual(200); + }); + + it('should move association item', async () => { + UserCollection.setField('posts', { + sortable: true, + type: 'hasMany', + }); + + await api.db.sync({ + alter: { + drop: false, + }, + force: false, + }); + + const PostCollection = api.db.getCollection('posts'); + + const sortFieldName = `${UserCollection.model.associations.posts.foreignKey}Sort`; + expect(PostCollection.fields.get(sortFieldName)).toBeDefined(); + + const u1 = await api.db.getRepository('users').findOne({ + filter: { + name: 'u1', + }, + }); + + await api + .agent() + .resource('users.posts', u1.get('id')) + .create({ + values: { + title: 'u1p4', + }, + }); + + const u1p4 = await api.db.getRepository('posts').findOne({ + filter: { + title: 'u1p4', + }, + }); + + // should move by association sort field + await api + .agent() + .resource('users.posts', u1.get('id')) + .move({ + sourceId: 1, + targetId: u1p4.get('id'), + }); + + const u1Posts = await api + .agent() + .resource('users.posts', u1.get('id')) + .list({ + fields: ['title'], + sort: [sortFieldName], + }); + + expect(u1Posts.body).toMatchObject({ + rows: [ + { + title: 'u1p2', + }, + { + title: 'u1p3', + }, + { + title: 'u1p4', + }, + { + title: 'u1p1', + }, + ], + }); + }); + }); + describe('same scope', () => { let api: MockServer; @@ -250,7 +398,7 @@ describe('sort action', () => { const beforeUpdatedAts = await getUpdatedAts(); - await sleep(1000); + await waitSecond(1000); await api.agent().resource('tests').move({ sourceId: moveItemId, @@ -301,7 +449,7 @@ describe('sort action', () => { }); const beforeUpdated = t1.get('updatedAt'); - await sleep(1000); + await waitSecond(1000); await api .agent() diff --git a/packages/core/actions/src/actions/list.ts b/packages/core/actions/src/actions/list.ts index 3df711f50..0f2054f61 100644 --- a/packages/core/actions/src/actions/list.ts +++ b/packages/core/actions/src/actions/list.ts @@ -20,6 +20,7 @@ function findArgs(ctx: Context) { } } const { tree, fields, filter, appends, except, sort } = params; + return { tree, filter, fields, appends, except, sort }; } diff --git a/packages/core/actions/src/actions/move.ts b/packages/core/actions/src/actions/move.ts index 595238d75..ec9b69780 100644 --- a/packages/core/actions/src/actions/move.ts +++ b/packages/core/actions/src/actions/move.ts @@ -1,7 +1,14 @@ -import { Op, Model } from 'sequelize'; +import { Model, Op } from 'sequelize'; import { Context } from '..'; -import { Collection, TargetKey, Repository, SortField } from '@nocobase/database'; +import { + BelongsToManyRepository, + Collection, + HasManyRepository, + Repository, + SortField, + TargetKey, +} from '@nocobase/database'; import { getRepositoryFromParams } from '../utils'; export async function move(ctx: Context, next) { @@ -9,24 +16,39 @@ export async function move(ctx: Context, next) { const { sourceId, targetId, sortField, targetScope, sticky, method } = ctx.action.params; - if (repository instanceof Repository) { - const sortAbleCollection = new SortAbleCollection(repository.collection, sortField); + if (repository instanceof BelongsToManyRepository) { + throw new Error("Sorting association as 'belongs-to-many' type is not supported."); + } - if (sourceId && targetId) { - await sortAbleCollection.move(sourceId, targetId, { - insertAfter: method === 'insertAfter', - }); - } - - // change scope - if (sourceId && targetScope) { - await sortAbleCollection.changeScope(sourceId, targetScope, method); - } - - if (sourceId && sticky) { - await sortAbleCollection.sticky(sourceId); + if (repository instanceof HasManyRepository) { + const hasManyField = repository.sourceCollection.getField(repository.associationName); + if (!hasManyField.options.sortable) { + throw new Error( + `association ${hasManyField.options.name} in ${repository.sourceCollection.name} is not sortable`, + ); } } + + const sortAbleCollection = new SortAbleCollection( + repository instanceof Repository ? repository.collection : repository.targetCollection, + repository instanceof Repository ? sortField : `${repository.association.foreignKey}Sort`, + ); + + if (sourceId && targetId) { + await sortAbleCollection.move(sourceId, targetId, { + insertAfter: method === 'insertAfter', + }); + } + + // change scope + if (sourceId && targetScope) { + await sortAbleCollection.changeScope(sourceId, targetScope, method); + } + + if (sourceId && sticky) { + await sortAbleCollection.sticky(sourceId); + } + ctx.body = 'ok'; await next(); } diff --git a/packages/core/client/src/block-provider/TableBlockProvider.tsx b/packages/core/client/src/block-provider/TableBlockProvider.tsx index 46b01659b..4ef57799b 100644 --- a/packages/core/client/src/block-provider/TableBlockProvider.tsx +++ b/packages/core/client/src/block-provider/TableBlockProvider.tsx @@ -81,9 +81,9 @@ export const TableBlockProvider = (props) => { const record = useRecord(); const collection = getCollection(props.collection); - const { treeTable } = fieldSchema?.['x-decorator-props'] || {}; + const { treeTable, dragSortBy } = fieldSchema?.['x-decorator-props'] || {}; if (props.dragSort) { - params['sort'] = ['sort']; + params['sort'] = dragSortBy || ['sort']; } let childrenColumnName = 'children'; if (collection?.tree && treeTable !== false) { diff --git a/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx b/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx index ac8bccfc1..f28b2e474 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx @@ -2,6 +2,7 @@ import { ArrayItems } from '@formily/antd-v5'; import { ISchema, useField, useFieldSchema } from '@formily/react'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useAPIClient } from '../../../api-client'; import { useFormBlockContext, useTableBlockContext } from '../../../block-provider'; import { mergeFilter } from '../../../block-provider/SharedFilterProvider'; import { useCollection, useCollectionManager } from '../../../collection-manager'; @@ -66,6 +67,7 @@ export const TableBlockDesigner = () => { }, [dn, field.decoratorProps, fieldSchema, service], ); + const api = useAPIClient(); return ( // fix https://nocobase.height.app/T-2259 @@ -95,10 +97,20 @@ export const TableBlockDesigner = () => { { + onChange={async (dragSort) => { + if (dragSort && collectionField) { + const { data } = await api.resource('collections.fields', collectionField.collectionName).update({ + filterByTk: collectionField.name, + values: { + sortable: true, + }, + }); + const sortBy = data?.data?.[0]?.sortBy; + fieldSchema['x-decorator-props'].dragSortBy = sortBy; + } field.decoratorProps.dragSort = dragSort; fieldSchema['x-decorator-props'].dragSort = dragSort; - service.run({ ...service.params?.[0], sort: 'sort' }); + service.run({ ...service.params?.[0], sort: fieldSchema['x-decorator-props'].dragSortBy }); dn.emit('patch', { schema: { ['x-uid']: fieldSchema['x-uid'], diff --git a/packages/core/database/src/fields/has-many-field.ts b/packages/core/database/src/fields/has-many-field.ts index d6b7b7c42..541d12ede 100644 --- a/packages/core/database/src/fields/has-many-field.ts +++ b/packages/core/database/src/fields/has-many-field.ts @@ -150,6 +150,20 @@ export class HasManyField extends RelationField { this.database.referenceMap.addReference(this.reference(association)); + // add sort field if association is sortable + if (this.options.sortable) { + const targetCollection = database.modelCollection.get(this.TargetModel); + const sortFieldName = `${this.options.foreignKey}Sort`; + + targetCollection.setField(sortFieldName, { + type: 'sort', + hidden: true, + scopeKey: this.options.foreignKey, + }); + + this.options.sortBy = sortFieldName; + } + return true; } diff --git a/packages/core/database/src/fields/sort-field.ts b/packages/core/database/src/fields/sort-field.ts index 31ca31173..973a1dfa8 100644 --- a/packages/core/database/src/fields/sort-field.ts +++ b/packages/core/database/src/fields/sort-field.ts @@ -82,12 +82,13 @@ export class SortField extends Field { const queryInterface = this.collection.db.sequelize.getQueryInterface(); if (scopeKey) { - const scopeField = this.collection.getField(scopeKey); - if (!scopeField) { + const scopeAttribute = this.collection.model.rawAttributes[scopeKey]; + + if (!scopeAttribute) { throw new Error(`can not find scope field ${scopeKey} for collection ${this.collection.name}`); } - scopeKey = this.collection.model.rawAttributes[scopeKey].field; + scopeKey = scopeAttribute.field; } const quotedOrderField = queryInterface.quoteIdentifier(orderField); diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/collections.repository.test.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/collections.repository.test.ts index 9afd6b02e..1b15f6c22 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/collections.repository.test.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/collections.repository.test.ts @@ -20,6 +20,24 @@ describe('collections repository', () => { await app.destroy(); }); + it('should create collection with sortable option', async () => { + await Collection.repository.create({ + values: { + name: 'posts', + fields: [ + { + type: 'string', + name: 'title', + }, + ], + sortable: true, + }, + context: {}, + }); + + expect(db.getCollection('posts').getField('sort')).toBeTruthy(); + }); + it('should create through table when pending fields', async () => { await Collection.repository.create({ values: { diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/fields/hasMany.test.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/fields/hasMany.test.ts index 4095ebb92..8e2f75b9c 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/fields/hasMany.test.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/fields/hasMany.test.ts @@ -17,11 +17,14 @@ describe('hasMany field options', () => { values: { name: 'tests', }, + context: {}, }); + await Collection.repository.create({ values: { name: 'foos', }, + context: {}, }); }); @@ -29,6 +32,56 @@ describe('hasMany field options', () => { await app.destroy(); }); + it('should create fields with sortable option', async () => { + const field = await Field.repository.create({ + values: { + type: 'hasMany', + collectionName: 'tests', + target: 'foos', + sortable: true, + foreignKey: 'test_id', + }, + context: {}, + }); + + await field.reload(); + expect(field.get('sortable')).toBe(true); + expect(field.get('sortBy')).toBe('test_idSort'); + }); + + it('should update field with sortable option', async () => { + const field = await Field.repository.create({ + values: { + type: 'hasMany', + collectionName: 'tests', + target: 'foos', + foreignKey: 'test_id', + }, + context: {}, + }); + + await field.reload(); + + expect(field.get('sortBy')).toBe(undefined); + + await Field.repository.update({ + values: { + sortable: true, + }, + filter: { + key: field.get('key'), + }, + context: {}, + }); + + await field.reload(); + + expect(field.get('sortBy')).toBe('test_idSort'); + const collection = db.getCollection('foos'); + const columns = await db.sequelize.getQueryInterface().describeTable(collection.getTableNameWithSchema()); + expect(columns).toHaveProperty(collection.model.rawAttributes['test_idSort'].field); + }); + it('should generate the foreignKey randomly', async () => { const field = await Field.repository.create({ values: { diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/http-api/has-many.test.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/http-api/has-many.test.ts new file mode 100644 index 000000000..272506e2a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/http-api/has-many.test.ts @@ -0,0 +1,60 @@ +import { MockServer } from '@nocobase/test'; +import { createApp } from '../index'; + +describe('collections repository', () => { + let app: MockServer; + let agent; + let db; + + beforeEach(async () => { + app = await createApp(); + db = app.db; + + agent = app.agent(); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should update belongs to many field sortable option', async () => { + await agent.resource('collections').create({ + values: { + name: 'users', + fields: [ + { + type: 'hasMany', + name: 'posts', + target: 'posts', + foreignKey: 'userId', + }, + ], + }, + }); + + await agent.resource('collections').create({ + values: { + name: 'posts', + }, + }); + + const postField = await db.getRepository('fields').findOne({ + filter: { + name: 'posts', + }, + }); + + // update has many field + const response = await agent.resource('collections.fields', 'users').update({ + values: { + sortable: true, + }, + filter: { + key: postField.get('key'), + }, + }); + + expect(response.body.data[0].sortable).toBe(true); + expect(response.body.data[0].sortBy).toBe('userIdSort'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/index.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/index.ts index bb26ce275..6e94f0236 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/index.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/__tests__/index.ts @@ -9,7 +9,6 @@ export async function createApp( const app = mockServer({ acl: false, ...options, - // plugins: ['error-handler', 'collection-manager', 'ui-schema-storage'], }); options.beforePlugin && options.beforePlugin(app); diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/hooks/beforeInitOptions.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/hooks/beforeInitOptions.ts index 4f3060a2a..1c7c172f8 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/hooks/beforeInitOptions.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/hooks/beforeInitOptions.ts @@ -73,6 +73,9 @@ export const beforeInitOptions = { } setTargetKey(database, model); setSourceKey(database, model); + if (model.get('sortable') && model.get('type') === 'hasMany') { + model.set('sortBy', model.get('foreignKey') + 'Sort'); + } }, hasOne(model: Model, { database }) { const defaults = { diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts index 08a3675d2..248c070eb 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/collection.ts @@ -25,6 +25,7 @@ export class CollectionModel extends MagicAttributeModel { ...this.get(), fields: [], }; + if (this.db.hasCollection(name)) { collection = this.db.getCollection(name); diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/field.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/field.ts index 3aa16db4a..43b16f989 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/field.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/models/field.ts @@ -48,6 +48,20 @@ export class FieldModel extends MagicAttributeModel { return field; } + async syncSortByField(options: Transactionable) { + const collectionName = this.get('collectionName'); + const collection = this.db.getCollection(collectionName); + await this.load(options); + await collection.sync({ + force: false, + alter: { + drop: false, + }, + // @ts-ignore + transaction: options.transaction, + }); + } + async migrate({ isNew, ...options }: MigrateOptions = {}) { let field; try { diff --git a/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts b/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts index c9c6d6b1a..e9a3aa3a0 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-collection-manager/src/server/server.ts @@ -13,12 +13,12 @@ import { beforeDestroyForeignKey, beforeInitOptions, } from './hooks'; +import { beforeCreateForValidateField } from './hooks/beforeCreateForValidateField'; import { beforeCreateForViewCollection } from './hooks/beforeCreateForViewCollection'; import { CollectionModel, FieldModel } from './models'; import collectionActions from './resourcers/collections'; -import viewResourcer from './resourcers/views'; import sqlResourcer from './resourcers/sql'; -import { beforeCreateForValidateField } from './hooks/beforeCreateForValidateField'; +import viewResourcer from './resourcers/views'; export class CollectionManagerPlugin extends Plugin { public schema: string; @@ -152,6 +152,10 @@ export class CollectionManagerPlugin extends Plugin { throw new Error('cant update field without a reverseField key'); } } + // todo: 目前只支持一对多 + if (model.get('sortable') && model.get('type') === 'hasMany') { + model.set('sortBy', model.get('foreignKey') + 'Sort'); + } }); this.app.db.on('fields.afterUpdate', async (model: FieldModel, { context, transaction }) => { @@ -180,6 +184,10 @@ export class CollectionManagerPlugin extends Plugin { if (prevOnDelete != currentOnDelete) { await model.syncReferenceCheckOption({ transaction }); } + + if (model.get('type') === 'hasMany' && model.get('sortable') && model.get('sortBy')) { + await model.syncSortByField({ transaction }); + } }); this.app.db.on('fields.afterSaveWithAssociations', async (model: FieldModel, { context, transaction }) => {