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 <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2023-11-18 22:02:21 +08:00 committed by GitHub
parent 635dcfdbd5
commit 0acd7b6dd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 385 additions and 31 deletions

View File

@ -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()

View File

@ -20,6 +20,7 @@ function findArgs(ctx: Context) {
}
}
const { tree, fields, filter, appends, except, sort } = params;
return { tree, filter, fields, appends, except, sort };
}

View File

@ -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();
}

View File

@ -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) {

View File

@ -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
<RecordProvider parent={record} record={{}}>
@ -95,10 +97,20 @@ export const TableBlockDesigner = () => {
<SchemaSettings.SwitchItem
title={t('Enable drag and drop sorting')}
checked={field.decoratorProps.dragSort}
onChange={(dragSort) => {
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'],

View File

@ -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;
}

View File

@ -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);

View File

@ -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: {

View File

@ -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: {

View File

@ -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');
});
});

View File

@ -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);

View File

@ -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 = {

View File

@ -25,6 +25,7 @@ export class CollectionModel extends MagicAttributeModel {
...this.get(),
fields: [],
};
if (this.db.hasCollection(name)) {
collection = this.db.getCollection(name);

View File

@ -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 {

View File

@ -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 }) => {