diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index 1d2792c3d8..37501c8986 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -1402,7 +1402,8 @@ export const useAssociationNames = (dataSource?: string) => { const collectionField = s['x-collection-field'] && getCollectionJoinField(s['x-collection-field'], dataSource); const isAssociationSubfield = s.name.includes('.'); const isAssociationField = - collectionField && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(collectionField.type); + collectionField && + ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'belongsToArray'].includes(collectionField.type); // 根据联动规则中条件的字段获取一些 appends if (s['x-linkage-rules']) { diff --git a/packages/core/client/src/collection-manager/interfaces/json.tsx b/packages/core/client/src/collection-manager/interfaces/json.tsx index d570047b4b..e534ee6d64 100644 --- a/packages/core/client/src/collection-manager/interfaces/json.tsx +++ b/packages/core/client/src/collection-manager/interfaces/json.tsx @@ -33,7 +33,7 @@ export class JsonFieldInterface extends CollectionFieldInterface { group = 'advanced'; order = 4; title = '{{t("JSON")}}'; - sortable = true; + sortable = false; default = { type: 'json', // name, @@ -50,7 +50,7 @@ export class JsonFieldInterface extends CollectionFieldInterface { default: null, }, }; - availableTypes = ['json', 'array', 'jsonb', 'text', 'circle', 'lineString', 'point', 'polygon']; + availableTypes = ['json', 'array', 'set', 'jsonb', 'text', 'circle', 'lineString', 'point', 'polygon']; hasDefaultValue = true; properties = { ...defaultProps, @@ -68,7 +68,7 @@ export class JsonFieldInterface extends CollectionFieldInterface { 'x-disabled': `{{ disabledJSONB }}`, }, }; - filterable = { - operators: operators.string, - }; + // filterable = { + // operators: operators.string, + // }; } diff --git a/packages/core/client/src/filter-provider/utils.ts b/packages/core/client/src/filter-provider/utils.ts index 60e9f66622..c125925837 100644 --- a/packages/core/client/src/filter-provider/utils.ts +++ b/packages/core/client/src/filter-provider/utils.ts @@ -169,7 +169,7 @@ export const useAssociatedFields = () => { }; export const isAssocField = (field?: FieldOptions) => { - return ['o2o', 'oho', 'obo', 'm2o', 'createdBy', 'updatedBy', 'o2m', 'm2m', 'linkTo', 'chinaRegion'].includes( + return ['o2o', 'oho', 'obo', 'm2o', 'createdBy', 'updatedBy', 'o2m', 'm2m', 'linkTo', 'chinaRegion', 'mbm'].includes( field?.interface, ); }; diff --git a/packages/core/client/src/modules/fields/component/Picker/recordPickerComponentFieldSettings.tsx b/packages/core/client/src/modules/fields/component/Picker/recordPickerComponentFieldSettings.tsx index 000d37fbb0..2488649c78 100644 --- a/packages/core/client/src/modules/fields/component/Picker/recordPickerComponentFieldSettings.tsx +++ b/packages/core/client/src/modules/fields/component/Picker/recordPickerComponentFieldSettings.tsx @@ -71,7 +71,7 @@ const allowMultiple: any = { useVisible() { const isFieldReadPretty = useIsFieldReadPretty(); const collectionField = useCollectionField(); - return !isFieldReadPretty && ['hasMany', 'belongsToMany'].includes(collectionField?.type); + return !isFieldReadPretty && ['hasMany', 'belongsToMany', 'belongsToArray'].includes(collectionField?.type); }, useComponentProps() { const { t } = useTranslation(); diff --git a/packages/core/client/src/schema-component/antd/association-field/AssociationFieldProvider.tsx b/packages/core/client/src/schema-component/antd/association-field/AssociationFieldProvider.tsx index 3cd2709ee1..2f774ea1b2 100644 --- a/packages/core/client/src/schema-component/antd/association-field/AssociationFieldProvider.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/AssociationFieldProvider.tsx @@ -72,7 +72,10 @@ export const AssociationFieldProvider = observer( if (field.value !== null && field.value !== undefined) { // Nester 子表单时,如果没数据初始化一个 [{}] 的占位 if (['Nester', 'PopoverNester'].includes(currentMode) && Array.isArray(field.value)) { - if (field.value.length === 0 && ['belongsToMany', 'hasMany'].includes(collectionField.type)) { + if ( + field.value.length === 0 && + ['belongsToMany', 'hasMany', 'belongsToArray'].includes(collectionField.type) + ) { field.value = [markRecordAsNew({})]; } } @@ -82,7 +85,7 @@ export const AssociationFieldProvider = observer( if (['Nester'].includes(currentMode)) { if (['belongsTo', 'hasOne'].includes(collectionField.type)) { field.value = {}; - } else if (['belongsToMany', 'hasMany'].includes(collectionField.type)) { + } else if (['belongsToMany', 'hasMany', 'belongsToArray'].includes(collectionField.type)) { field.value = [markRecordAsNew({})]; } } diff --git a/packages/core/client/src/schema-component/antd/association-field/InternalPicker.tsx b/packages/core/client/src/schema-component/antd/association-field/InternalPicker.tsx index 09a16eff5b..81acd1b72b 100644 --- a/packages/core/client/src/schema-component/antd/association-field/InternalPicker.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/InternalPicker.tsx @@ -105,7 +105,7 @@ export const InternalPicker = observer( const pickerProps = { size: 'small', fieldNames, - multiple: multiple !== false && ['o2m', 'm2m'].includes(collectionField?.interface), + multiple: multiple !== false && ['o2m', 'm2m', 'mbm'].includes(collectionField?.interface), association: { target: collectionField?.target, }, @@ -142,7 +142,7 @@ export const InternalPicker = observer( setVisible(false); }, style: { - display: multiple !== false && ['o2m', 'm2m'].includes(collectionField?.interface) ? 'block' : 'none', + display: multiple !== false && ['o2m', 'm2m', 'mbm'].includes(collectionField?.interface) ? 'block' : 'none', }, }; }; diff --git a/packages/core/client/src/schema-component/antd/association-field/Nester.tsx b/packages/core/client/src/schema-component/antd/association-field/Nester.tsx index d4e8236d3d..fb12bec82f 100644 --- a/packages/core/client/src/schema-component/antd/association-field/Nester.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/Nester.tsx @@ -43,7 +43,7 @@ export const Nester = (props) => { ); } - if (['hasMany', 'belongsToMany'].includes(options.type)) { + if (['hasMany', 'belongsToMany', 'belongsToArray'].includes(options.type)) { return ( diff --git a/packages/core/client/src/schema-component/antd/form-item/FormItem.Settings.tsx b/packages/core/client/src/schema-component/antd/form-item/FormItem.Settings.tsx index 8a3fbf1e47..e96c47884b 100644 --- a/packages/core/client/src/schema-component/antd/form-item/FormItem.Settings.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/FormItem.Settings.tsx @@ -982,7 +982,7 @@ function useFormItemCollectionField() { export function useIsAssociationField() { const collectionField = useFormItemCollectionField(); - const isAssociationField = ['obo', 'oho', 'o2o', 'o2m', 'm2m', 'm2o', 'updatedBy', 'createdBy'].includes( + const isAssociationField = ['obo', 'oho', 'o2o', 'o2m', 'm2m', 'm2o', 'updatedBy', 'createdBy', 'mbm'].includes( collectionField?.interface, ); return isAssociationField; diff --git a/packages/core/client/src/schema-component/hooks/useFieldModeOptions.tsx b/packages/core/client/src/schema-component/hooks/useFieldModeOptions.tsx index 7b2c34f515..085d2b9919 100644 --- a/packages/core/client/src/schema-component/hooks/useFieldModeOptions.tsx +++ b/packages/core/client/src/schema-component/hooks/useFieldModeOptions.tsx @@ -32,7 +32,7 @@ export const useFieldModeOptions = (props?) => { return; } if ( - !['o2o', 'oho', 'obo', 'o2m', 'linkTo', 'm2o', 'm2m', 'updatedBy', 'createdBy'].includes( + !['o2o', 'oho', 'obo', 'o2m', 'linkTo', 'm2o', 'm2m', 'updatedBy', 'createdBy', 'mbm'].includes( collectionField.interface, ) ) @@ -86,6 +86,7 @@ export const useFieldModeOptions = (props?) => { !isTableField && { label: t('Sub-table'), value: 'SubTable' }, ]; case 'm2m': + case 'mbm': return isReadPretty ? [ { label: t('Title'), value: 'Select' }, diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index 5bab0cd246..058f17810c 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -306,7 +306,7 @@ export const useFormItemInitializerFields = (options?: any) => { const targetCollection = getCollection(field.target); const isFileCollection = field?.target && getCollection(field?.target)?.template === 'file'; const isAssociationField = targetCollection; - const fieldNames = field?.uiSchema['x-component-props']?.['fieldNames']; + const fieldNames = field?.uiSchema?.['x-component-props']?.['fieldNames']; const schema = { type: 'string', name: field.name, diff --git a/packages/core/database/src/eager-loading/eager-loading-tree.ts b/packages/core/database/src/eager-loading/eager-loading-tree.ts index e4a696f192..b3d6dc3c30 100644 --- a/packages/core/database/src/eager-loading/eager-loading-tree.ts +++ b/packages/core/database/src/eager-loading/eager-loading-tree.ts @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import lodash from 'lodash'; +import lodash, { flatten } from 'lodash'; import { Association, HasOne, HasOneOptions, Includeable, Model, ModelStatic, Op, Transaction } from 'sequelize'; import Database from '../database'; import { appendChildCollectionNameAfterRepositoryFind } from '../listeners/append-child-collection-name-after-repository-find'; @@ -225,6 +225,16 @@ export class EagerLoadingTree { throw new Error(`Model ${node.model.name} does not have primary key`); } + includeForFilter.forEach((include: { association: string }, index: number) => { + const association = node.model.associations[include.association]; + if (association?.associationType == 'BelongsToArray') { + includeForFilter[index] = { + ...include, + ...association.generateInclude(), + }; + } + }); + // find all ids const ids = ( await node.model.findAll({ @@ -256,10 +266,13 @@ export class EagerLoadingTree { // clear filter association value const associations = node.model.associations; - for (const association of Object.keys(associations)) { + for (const [name, association] of Object.entries(associations)) { + // if ((association as any).associationType === 'belongsToArray') { + // continue; + // } for (const instance of instances) { - delete instance[association]; - delete instance.dataValues[association]; + delete instance[name]; + delete instance.dataValues[name]; } } } else if (ids.length > 0) { @@ -302,6 +315,30 @@ export class EagerLoadingTree { instances = await node.model.findAll(findOptions); } + if (associationType === 'BelongsToArray') { + const targetKey = association.targetKey; + const targetKeyValues = node.parent.instances.map((instance) => { + return instance.get(association.foreignKey); + }); + + let where: any = { [targetKey]: Array.from(new Set(flatten(targetKeyValues))) }; + + if (node.where) { + where = { + [Op.and]: [where, node.where], + }; + } + + const findOptions = { + where, + attributes: node.attributes, + order: params.order || orderOption(association), + transaction, + }; + + instances = await node.model.findAll(findOptions); + } + if (associationType == 'BelongsTo') { const foreignKey = association.foreignKey; const parentInstancesForeignKeyValues = node.parent.instances.map((instance) => instance.get(foreignKey)); @@ -405,6 +442,10 @@ export class EagerLoadingTree { const setParentAccessor = (parentInstance) => { const key = association.as; + if (!key) { + return; + } + const children = parentInstance.getDataValue(association.as); if (association.isSingleAssociation) { @@ -443,6 +484,23 @@ export class EagerLoadingTree { } } + if (associationType === 'BelongsToArray') { + const { foreignKey, targetKey } = association; + + const instanceMap = node.instances.reduce((mp: { [targetKey: string]: Model }, instance: Model) => { + mp[instance.get(targetKey)] = instance; + return mp; + }, {}); + + node.parent.instances.forEach((parentInstance: Model) => { + const targetKeys = parentInstance.getDataValue(foreignKey); + parentInstance.setDataValue( + association.as, + targetKeys?.map((targetKey: any) => instanceMap[targetKey]).filter(Boolean), + ); + }); + } + if (associationType == 'BelongsTo') { const foreignKey = association.foreignKey; const targetKey = association.targetKey; diff --git a/packages/core/database/src/fields/array-field.ts b/packages/core/database/src/fields/array-field.ts index 8885301072..46a2c9046e 100644 --- a/packages/core/database/src/fields/array-field.ts +++ b/packages/core/database/src/fields/array-field.ts @@ -12,7 +12,11 @@ import { BaseColumnFieldOptions, Field } from './field'; export class ArrayField extends Field { get dataType() { + const { dataType, elementType = '' } = this.options; if (this.database.sequelize.getDialect() === 'postgres') { + if (dataType === 'array') { + return new DataTypes.ARRAY(DataTypes[elementType.toUpperCase()]); + } return DataTypes.JSONB; } @@ -44,4 +48,6 @@ export class ArrayField extends Field { export interface ArrayFieldOptions extends BaseColumnFieldOptions { type: 'array'; + dataType?: 'array' | 'json'; + elementType: DataTypes.DataType; } diff --git a/packages/core/database/src/fields/set-field.ts b/packages/core/database/src/fields/set-field.ts index f0c66fbab5..1816c75851 100644 --- a/packages/core/database/src/fields/set-field.ts +++ b/packages/core/database/src/fields/set-field.ts @@ -7,10 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { ArrayField } from './array-field'; -import { BaseColumnFieldOptions } from './field'; +import { ArrayField, ArrayFieldOptions } from './array-field'; -export interface SetFieldOptions extends BaseColumnFieldOptions { +export interface SetFieldOptions extends Omit { type: 'set'; } diff --git a/packages/core/database/src/filter-parser.ts b/packages/core/database/src/filter-parser.ts index a21ea5f600..6f67151914 100644 --- a/packages/core/database/src/filter-parser.ts +++ b/packages/core/database/src/filter-parser.ts @@ -13,6 +13,7 @@ import { ModelStatic } from 'sequelize'; import { Collection } from './collection'; import { Database } from './database'; import { Model } from './model'; +import { BelongsToArrayAssociation } from './relation-repository/belongs-to-array-repository'; const debug = require('debug')('noco-database'); @@ -93,7 +94,9 @@ export default class FilterParser { debug('associations %O', associations); - for (let [key, value] of Object.entries(flattenedFilter)) { + for (const entry of Object.entries(flattenedFilter)) { + const key = entry[0]; + let value = entry[1]; // 处理 filter 条件 if (skipPrefix && key.startsWith(skipPrefix)) { continue; @@ -167,6 +170,7 @@ export default class FilterParser { continue; } + const association = associations[firstKey]; const associationKeys = []; associationKeys.push(firstKey); @@ -177,10 +181,17 @@ export default class FilterParser { if (!existInclude) { // set sequelize include option - _.set(include, firstKey, { + let includeOptions = { association: firstKey, attributes: [], // out put empty fields by default - }); + }; + if (association.associationType === 'BelongsToArray') { + includeOptions = { + ...includeOptions, + ...(association as any as BelongsToArrayAssociation).generateInclude(), + }; + } + _.set(include, firstKey, includeOptions); } // association target model diff --git a/packages/core/database/src/index.ts b/packages/core/database/src/index.ts index d54dd11ded..fcc38b4220 100644 --- a/packages/core/database/src/index.ts +++ b/packages/core/database/src/index.ts @@ -44,6 +44,7 @@ export * from './relation-repository/belongs-to-repository'; export * from './relation-repository/hasmany-repository'; export * from './relation-repository/multiple-relation-repository'; export * from './relation-repository/single-relation-repository'; +export * from './relation-repository/belongs-to-array-repository'; export * from './repository'; export * from './update-associations'; export { snakeCase } from './utils'; diff --git a/packages/core/database/src/model.ts b/packages/core/database/src/model.ts index 0194250137..9c56c81cf9 100644 --- a/packages/core/database/src/model.ts +++ b/packages/core/database/src/model.ts @@ -127,6 +127,13 @@ export class Model traverseJSON(item, opts)); + } else if (association.associationType === 'BelongsToArray') { + const value = data[key]; + if (!value || value.some((v) => typeof v !== 'object')) { + result[key] = value; + } else { + result[key] = handleArray(data[key], opts).map((item) => traverseJSON(item, opts)); + } } else { result[key] = data[key] ? traverseJSON(data[key], opts) : null; } diff --git a/packages/core/database/src/relation-repository/belongs-to-array-repository.ts b/packages/core/database/src/relation-repository/belongs-to-array-repository.ts new file mode 100644 index 0000000000..ba574a2310 --- /dev/null +++ b/packages/core/database/src/relation-repository/belongs-to-array-repository.ts @@ -0,0 +1,112 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { omit } from 'lodash'; +import { Transactionable } from 'sequelize/types'; +import { Collection } from '../collection'; +import { transactionWrapperBuilder } from '../decorators/transaction-decorator'; +import { FindOptions } from '../repository'; +import { MultipleRelationRepository } from './multiple-relation-repository'; +import Database from '../database'; +import { Model } from '../model'; + +const transaction = transactionWrapperBuilder(function () { + return this.collection.model.sequelize.transaction(); +}); + +export class BelongsToArrayAssociation { + db: Database; + associationType: string; + source: Model; + foreignKey: string; + targetName: string; + targetKey: string; + identifierField: string; + as: string; + + constructor(options: { + db: Database; + source: Model; + as: string; + foreignKey: string; + target: string; + targetKey: string; + }) { + const { db, source, as, foreignKey, target, targetKey } = options; + this.associationType = 'BelongsToArray'; + this.db = db; + this.source = source; + this.foreignKey = foreignKey; + this.targetName = target; + this.targetKey = targetKey; + this.identifierField = 'undefined'; + this.as = as; + } + + get target() { + return this.db.getModel(this.targetName); + } + + generateInclude() { + if (this.db.sequelize.getDialect() !== 'postgres') { + throw new Error('Filtering by many to many (array) associations is only supported on postgres'); + } + const targetCollection = this.db.getCollection(this.targetName); + const targetField = targetCollection.getField(this.targetKey); + const sourceCollection = this.db.getCollection(this.source.name); + const foreignField = sourceCollection.getField(this.foreignKey); + const queryInterface = this.db.sequelize.getQueryInterface(); + const left = queryInterface.quoteIdentifiers(`${this.as}.${targetField.columnName()}`); + const right = queryInterface.quoteIdentifiers(`${this.source.collection.name}.${foreignField.columnName()}`); + return { + on: this.db.sequelize.literal(`${left}=any(${right})`), + }; + } +} + +export class BelongsToArrayRepository extends MultipleRelationRepository { + private belongsToArrayAssociation: BelongsToArrayAssociation; + + constructor(sourceCollection: Collection, association: string, sourceKeyValue: string | number) { + super(sourceCollection, association, sourceKeyValue); + + this.belongsToArrayAssociation = this.association as any as BelongsToArrayAssociation; + } + + protected getInstance(options: Transactionable) { + return this.sourceCollection.repository.findOne({ + filterByTk: this.sourceKeyValue, + }); + } + + @transaction() + async find(options?: FindOptions): Promise { + const targetRepository = this.targetCollection.repository; + const instance = await this.getInstance(options); + const tks = instance.get(this.belongsToArrayAssociation.foreignKey); + const targetKey = this.belongsToArrayAssociation.targetKey; + + const addFilter = { + [targetKey]: tks, + }; + + if (options?.filterByTk) { + addFilter[targetKey] = options.filterByTk; + } + + const findOptions = { + ...omit(options, ['filterByTk', 'where', 'values', 'attributes']), + filter: { + $and: [options.filter || {}, addFilter], + }, + }; + + return await targetRepository.find(findOptions); + } +} diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index ca7a72147b..c407060021 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -45,6 +45,7 @@ import { HasOneRepository } from './relation-repository/hasone-repository'; import { RelationRepository } from './relation-repository/relation-repository'; import { updateAssociations, updateModelByValues } from './update-associations'; import { UpdateGuard } from './update-guard'; +import { BelongsToArrayRepository } from './relation-repository/belongs-to-array-repository'; const debug = require('debug')('noco-database'); @@ -188,6 +189,7 @@ class RelationRepositoryBuilder { BelongsToMany: BelongsToManyRepository, HasMany: HasManyRepository, ArrayField: ArrayFieldRepository, + BelongsToArray: BelongsToArrayRepository, }; constructor(collection: Collection, associationName: string) { @@ -195,13 +197,17 @@ class RelationRepositoryBuilder { this.associationName = associationName; this.association = this.collection.model.associations[this.associationName]; - if (!this.association) { - const field = collection.getField(associationName); - if (field && field instanceof ArrayField) { - this.association = { - associationType: 'ArrayField', - }; - } + if (this.association) { + return; + } + const field = collection.getField(associationName); + if (!field) { + return; + } + if (field instanceof ArrayField) { + this.association = { + associationType: 'ArrayField', + }; } } diff --git a/packages/core/database/src/view/field-type-map.ts b/packages/core/database/src/view/field-type-map.ts index d51272f972..8d18f30613 100644 --- a/packages/core/database/src/view/field-type-map.ts +++ b/packages/core/database/src/view/field-type-map.ts @@ -40,6 +40,8 @@ const postgres = { polygon: 'json', circle: 'json', uuid: 'uuid', + set: 'set', + array: 'array', }; const mysql = { diff --git a/packages/core/test/src/server/index.ts b/packages/core/test/src/server/index.ts index 00ed70ad4a..71bf32dac7 100644 --- a/packages/core/test/src/server/index.ts +++ b/packages/core/test/src/server/index.ts @@ -10,7 +10,7 @@ import { describe } from 'vitest'; import ws from 'ws'; -export { mockDatabase } from '@nocobase/database'; +export { mockDatabase, MockDatabase } from '@nocobase/database'; export { default as supertest } from 'supertest'; export * from './mockServer'; diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/client/__e2e__/collection-template/general.test.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/client/__e2e__/collection-template/general.test.ts index 8023ddacc1..d5037349e2 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/client/__e2e__/collection-template/general.test.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/client/__e2e__/collection-template/general.test.ts @@ -250,7 +250,7 @@ test.describe('association constraints support selecting non-primary key fields await page.getByRole('button', { name: 'Submit' }).click(); await page.getByLabel(`action-Action.Link-Configure fields-collections-${collectionName}`).click(); await page.getByRole('button', { name: 'plus Add field' }).click(); - await page.getByRole('menuitem', { name: 'Many to many' }).click(); + await page.getByRole('menuitem', { name: /^Many to many$/ }).click(); await page.getByLabel('block-item-SourceKey-fields-').click(); // sourceKey 选项符合预期 diff --git a/packages/plugins/@nocobase/plugin-data-source-main/src/client/__e2e__/fields/json/schemaSettings.test.ts b/packages/plugins/@nocobase/plugin-data-source-main/src/client/__e2e__/fields/json/schemaSettings.test.ts index 1be472dd98..fc38899ee5 100644 --- a/packages/plugins/@nocobase/plugin-data-source-main/src/client/__e2e__/fields/json/schemaSettings.test.ts +++ b/packages/plugins/@nocobase/plugin-data-source-main/src/client/__e2e__/fields/json/schemaSettings.test.ts @@ -90,7 +90,7 @@ test.describe('table column & table', () => { await createColumnItem(page, 'JSON'); await showSettingsMenu(page, 'JSON'); }, - supportedOptions: ['Custom column title', 'Column width', 'Sortable', 'Delete'], + supportedOptions: ['Custom column title', 'Column width', 'Delete'], }); }); }); diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/server/services/type-interface-map.ts b/packages/plugins/@nocobase/plugin-data-source-manager/src/server/services/type-interface-map.ts index 8bcd28a0aa..637efd24f7 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/server/services/type-interface-map.ts +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/server/services/type-interface-map.ts @@ -10,7 +10,21 @@ /* istanbul ignore file -- @preserve */ const typeInterfaceMap = { - array: '', + array: () => { + return { + interface: 'json', + uiSchema: { + 'x-component': 'Input.JSON', + 'x-component-props': { + autoSize: { + minRows: 5, + // maxRows: 20, + }, + }, + default: null, + }, + }; + }, belongsTo: '', belongsToMany: '', boolean: () => { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts index a9f919c952..474d6d95c5 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts @@ -8,7 +8,7 @@ */ import { Context, Next } from '@nocobase/actions'; -import { Field, FilterParser } from '@nocobase/database'; +import { BelongsToArrayAssociation, Field, FilterParser } from '@nocobase/database'; import { formatter } from './formatter'; import compose from 'koa-compose'; import { Cache } from '@nocobase/cache'; @@ -190,6 +190,7 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => { const db = getDB(ctx, dataSource) || ctx.db; const collection = db.getCollection(collectionName); const fields = collection.fields; + const associations = collection.model.associations; const models: { [target: string]: { type: string; @@ -234,11 +235,25 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => { const parsedMeasures = measures?.map(parseField) || []; const parsedDimensions = dimensions?.map(parseField) || []; const parsedOrders = orders?.map(parseField) || []; - const include = Object.entries(models).map(([target, { type }]) => ({ - association: target, - attributes: [], - ...(type === 'belongsToMany' ? { through: { attributes: [] } } : {}), - })); + const include = Object.entries(models).map(([target, { type }]) => { + let options = { + association: target, + attributes: [], + }; + if (type === 'belongsToMany') { + options['through'] = { attributes: [] }; + } + if (type === 'belongsToArray') { + const association = associations[target] as BelongsToArrayAssociation; + if (association) { + options = { + ...options, + ...association.generateInclude(), + }; + } + } + return options; + }); const filterParser = new FilterParser(filter, { collection, diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/.npmignore b/packages/plugins/@nocobase/plugin-field-m2m-array/.npmignore new file mode 100644 index 0000000000..65f5e8779f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/README.md b/packages/plugins/@nocobase/plugin-field-m2m-array/README.md new file mode 100644 index 0000000000..cb1506ac29 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/README.md @@ -0,0 +1 @@ +# @nocobase/plugin-field-record-set diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/client.d.ts b/packages/plugins/@nocobase/plugin-field-m2m-array/client.d.ts new file mode 100644 index 0000000000..6c459cbac4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/client.js b/packages/plugins/@nocobase/plugin-field-m2m-array/client.js new file mode 100644 index 0000000000..b6e3be70e6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/package.json b/packages/plugins/@nocobase/plugin-field-m2m-array/package.json new file mode 100644 index 0000000000..38b034ed01 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/package.json @@ -0,0 +1,16 @@ +{ + "name": "@nocobase/plugin-field-m2m-array", + "displayName": "Collection field: Many to many (array)", + "displayName.zh-CN": "数据表字段:多对多 (数组)", + "version": "1.3.0-alpha", + "main": "dist/server/index.js", + "dependencies": {}, + "peerDependencies": { + "@nocobase/client": "1.x", + "@nocobase/server": "1.x", + "@nocobase/test": "1.x" + }, + "keywords": [ + "Collection fields" + ] +} diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/server.d.ts b/packages/plugins/@nocobase/plugin-field-m2m-array/server.d.ts new file mode 100644 index 0000000000..c41081ddc6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/server.js b/packages/plugins/@nocobase/plugin-field-m2m-array/server.js new file mode 100644 index 0000000000..972842039a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/src/client/ForeignKey.tsx b/packages/plugins/@nocobase/plugin-field-m2m-array/src/client/ForeignKey.tsx new file mode 100644 index 0000000000..739cb5ffcc --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/src/client/ForeignKey.tsx @@ -0,0 +1,73 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { observer, useField } from '@formily/react'; +import { AutoComplete, Select } from 'antd'; +import React, { useState, useEffect } from 'react'; +import { useRecord, useCompile } from '@nocobase/client'; +import { useMBMFields } from './hooks'; + +export const ForeignKey = observer( + (props: any) => { + const { disabled } = props; + const [options, setOptions] = useState([]); + const record = useRecord(); + const field: any = useField(); + const { type, template } = record; + const value = record[field.props.name]; + const compile = useCompile(); + const [initialValue, setInitialValue] = useState(value || (template === 'view' ? null : field.initialValue)); + const { foreignKeys } = useMBMFields(); + useEffect(() => { + const fields = foreignKeys; + if (fields) { + const sourceOptions = fields.map((k) => { + return { + value: k.name, + label: compile(k.uiSchema?.title || k.name), + }; + }); + setOptions(sourceOptions); + if (value) { + const option = sourceOptions.find((v) => v.value === value); + setInitialValue(option?.label || value); + } + } + }, [type]); + const Component = template === 'view' ? Select : AutoComplete; + return ( +
+ { + const fields = foreignKeys; + if (fields && open) { + setOptions( + fields.map((k) => { + return { + value: k.name, + label: compile(k.uiSchema?.title || k.name), + }; + }), + ); + } + }} + onChange={(value, option) => { + props?.onChange?.(value); + setInitialValue(option.label || value); + }} + /> +
+ ); + }, + { displayName: 'MBMForeignKey' }, +); diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/src/client/TargetKey.tsx b/packages/plugins/@nocobase/plugin-field-m2m-array/src/client/TargetKey.tsx new file mode 100644 index 0000000000..e225b1ea15 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-m2m-array/src/client/TargetKey.tsx @@ -0,0 +1,66 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { useEffect, useState } from 'react'; +import { Select } from 'antd'; +import { observer, useField } from '@formily/react'; +import { useRecord, useCompile } from '@nocobase/client'; +import { useMBMFields } from './hooks'; + +export const TargetKey = observer( + (props: any) => { + const { value, disabled } = props; + const { targetKey } = useRecord(); + const [options, setOptions] = useState([]); + const [initialValue, setInitialValue] = useState(value || targetKey); + const compile = useCompile(); + const field: any = useField(); + field.required = true; + const { targetKeys } = useMBMFields(); + useEffect(() => { + if (targetKeys) { + setOptions( + targetKeys.map((k) => { + return { + value: k.name, + label: compile(k?.uiSchema?.title || k.title || k.name), + }; + }), + ); + } + }, [targetKeys]); + return ( +
+