diff --git a/packages/datasource-customizer/src/collection-customizer.ts b/packages/datasource-customizer/src/collection-customizer.ts index 86b7f90aed..fc5545bdbc 100644 --- a/packages/datasource-customizer/src/collection-customizer.ts +++ b/packages/datasource-customizer/src/collection-customizer.ts @@ -437,6 +437,20 @@ export default class CollectionCustomizer< }); } + /** + * Disable filtering on a specific field for the end-user. + * Operators stay available to the customizations, so the field can still be used + * as the key of a custom relation. + * @param name the name of the field with filtering to be disabled + * @example + * .disableFieldFiltering('authorId'); + */ + disableFieldFiltering(name: TColumnName): this { + return this.pushCustomization(async () => { + this.stack.schema.getCollection(this.name).disableFieldFiltering(name); + }); + } + /** * Enable sorting on a specific field using emulation. * As for all the emulation method, the field sorting will be done in-memory. diff --git a/packages/datasource-customizer/src/decorators/schema/collection.ts b/packages/datasource-customizer/src/decorators/schema/collection.ts index ded0e47fe6..d7210c9d92 100644 --- a/packages/datasource-customizer/src/decorators/schema/collection.ts +++ b/packages/datasource-customizer/src/decorators/schema/collection.ts @@ -1,21 +1,45 @@ -import type { CollectionSchema } from '@forestadmin/datasource-toolkit'; +import type { CollectionSchema, ColumnSchema } from '@forestadmin/datasource-toolkit'; -import { CollectionDecorator } from '@forestadmin/datasource-toolkit'; +import { + CollectionDecorator, + FieldValidator, + ValidationError, +} from '@forestadmin/datasource-toolkit'; /** * This decorator allows to override parts of the collections schema. * It can be used to toggle off collection level capabilities for performance - * (for now, list-view counts and the search bar) + * (for now, list-view counts and the search bar), or to hide filter operators + * that decorators below still rely on (e.g. 'In' on a custom relation key). */ export default class SchemaCollectionDecorator extends CollectionDecorator { private schemaOverride: Partial = {}; + private unfilterableFields = new Set(); overrideSchema(value: Partial): void { Object.assign(this.schemaOverride, value); this.markSchemaAsDirty(); } + disableFieldFiltering(name: string): void { + FieldValidator.validate(this, name); + + if ((this.childCollection.schema.fields[name] as ColumnSchema).isPrimaryKey) { + throw new ValidationError(`Cannot disable filtering on primary key '${this.name}.${name}'`); + } + + this.unfilterableFields.add(name); + this.markSchemaAsDirty(); + } + protected override refineSchema(subSchema: CollectionSchema): CollectionSchema { - return { ...subSchema, ...this.schemaOverride }; + const fields = { ...subSchema.fields }; + + // Empty Set rather than undefined: FilterFactory calls filterOperators.has() unguarded + for (const name of this.unfilterableFields) { + fields[name] = { ...(fields[name] as ColumnSchema), filterOperators: new Set() }; + } + + return { ...subSchema, fields, ...this.schemaOverride }; } } diff --git a/packages/datasource-customizer/test/collection-customizer.test.ts b/packages/datasource-customizer/test/collection-customizer.test.ts index a459a7d4a8..250a750697 100644 --- a/packages/datasource-customizer/test/collection-customizer.test.ts +++ b/packages/datasource-customizer/test/collection-customizer.test.ts @@ -132,6 +132,53 @@ describe('Builder > Collection', () => { }); }); + describe('disableFieldFiltering', () => { + it('should remove the operators of the field from the schema', async () => { + const { dsc, customizer } = await setup(); + + const self = customizer.disableFieldFiltering('firstName'); + await dsc.getDataSource(logger); + + expect(self.schema.fields.firstName).toEqual( + expect.objectContaining({ filterOperators: new Set() }), + ); + expect(self).toEqual(customizer); + }); + + it('should still allow the field to be the key of a custom one to one', async () => { + const { dsc, customizer, bookCustomizer } = await setup(); + + customizer.emulateFieldOperator('firstName', 'In').disableFieldFiltering('firstName'); + bookCustomizer + .emulateFieldOperator('title', 'In') + .addOneToOneRelation('myAuthor', 'authors', { + originKey: 'firstName', + originKeyTarget: 'title', + }); + await dsc.getDataSource(logger); + + expect(bookCustomizer.schema.fields.myAuthor).toEqual({ + type: 'OneToOne', + foreignCollection: 'authors', + originKey: 'firstName', + originKeyTarget: 'title', + }); + expect(customizer.schema.fields.firstName).toEqual( + expect.objectContaining({ filterOperators: new Set() }), + ); + }); + + it('should throw on a primary key', async () => { + const { dsc, customizer } = await setup(); + + customizer.disableFieldFiltering('authorId'); + + await expect(dsc.getDataSource(logger)).rejects.toThrow( + "Cannot disable filtering on primary key 'authors.authorId'", + ); + }); + }); + describe('disableSearch', () => { it('should edit the schema', async () => { const { dsc, customizer } = await setup(); diff --git a/packages/datasource-customizer/test/decorators/schema/collection.test.ts b/packages/datasource-customizer/test/decorators/schema/collection.test.ts index 1a10d8b607..6e065aa89d 100644 --- a/packages/datasource-customizer/test/decorators/schema/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/schema/collection.test.ts @@ -14,4 +14,83 @@ describe('SchemaCollectionDecorator', () => { expect(collection.schema.countable).toBe(true); expect(decorator.schema.countable).toBe(false); }); + + describe('disableFieldFiltering', () => { + const buildDecorator = () => { + const collection = factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build({ + filterOperators: new Set(['Equal', 'In']), + }), + authorId: factories.columnSchema.build({ + filterOperators: new Set(['Equal', 'In', 'Present']), + }), + title: factories.columnSchema.build({ filterOperators: new Set(['Equal']) }), + author: factories.manyToOneSchema.build({ + foreignCollection: 'authors', + foreignKey: 'authorId', + }), + }, + }), + }); + + return { collection, decorator: new SchemaCollectionDecorator(collection, null) }; + }; + + it('should expose an empty operator set on the field', () => { + const { decorator } = buildDecorator(); + + decorator.disableFieldFiltering('authorId'); + + expect(decorator.schema.fields.authorId).toEqual( + expect.objectContaining({ type: 'Column', filterOperators: new Set() }), + ); + }); + + it('should keep the operators of the child collection', () => { + const { collection, decorator } = buildDecorator(); + + decorator.disableFieldFiltering('authorId'); + + expect(collection.schema.fields.authorId).toEqual( + expect.objectContaining({ filterOperators: new Set(['Equal', 'In', 'Present']) }), + ); + }); + + it('should leave the other fields untouched', () => { + const { decorator } = buildDecorator(); + + decorator.disableFieldFiltering('authorId'); + + expect(decorator.schema.fields.title).toEqual( + expect.objectContaining({ filterOperators: new Set(['Equal']) }), + ); + }); + + it('should throw on a primary key', () => { + const { decorator } = buildDecorator(); + + expect(() => decorator.disableFieldFiltering('id')).toThrow( + "Cannot disable filtering on primary key 'books.id'", + ); + }); + + it('should throw on a relation', () => { + const { decorator } = buildDecorator(); + + expect(() => decorator.disableFieldFiltering('author')).toThrow( + "Unexpected field type: 'books.author' (found 'ManyToOne' expected 'Column')", + ); + }); + + it('should throw on an unknown field', () => { + const { decorator } = buildDecorator(); + + expect(() => decorator.disableFieldFiltering('unknown')).toThrow( + "The 'books.unknown' field was not found", + ); + }); + }); });