Files field - add files field display and input + filtering (#17637)

This PR introduces a new FILES field type for Twenty CRM, allowing users
to attach multiple files to any record.

- Files field display
- Files field preview
- Files field input
- Files field filtering

To test : 
1/ need to activate feature flag `IS_FILES_FIELD_ENABLED` + create a new
FILES field

- display in read only, edit, inline, table
- edit
- filter
- export


closes https://github.com/twentyhq/core-team-issues/issues/2154


<img width="354" height="134" alt="Screenshot 2026-02-02 at 19 11 01"
src="https://github.com/user-attachments/assets/3c3de89e-f6b6-4526-b710-e4c7ee1a6d30"
/>
<img width="1081" height="933" alt="Screenshot 2026-02-02 at 19 10 41"
src="https://github.com/user-attachments/assets/7c9a7278-edb7-4d4a-882c-f7404689d011"
/>
<img width="1073" height="719" alt="Screenshot 2026-02-02 at 19 10 33"
src="https://github.com/user-attachments/assets/79cc372b-56a2-4cbf-b4fe-023adc4753a8"
/>
This commit is contained in:
Etienne
2026-02-04 11:55:48 +01:00
committed by GitHub
parent 9b8eacb7f7
commit 4fcc424e24
73 changed files with 1872 additions and 455 deletions
@@ -19,6 +19,7 @@ export * from './utils/isMatchingArrayFilter';
export * from './utils/isMatchingBooleanFilter';
export * from './utils/isMatchingCurrencyFilter';
export * from './utils/isMatchingDateFilter';
export * from './utils/isMatchingFilesFilter';
export * from './utils/isMatchingFloatFilter';
export * from './utils/isMatchingMultiSelectFilter';
export * from './utils/isMatchingRatingFilter';
@@ -9,6 +9,7 @@ import {
type BooleanFilter,
type CurrencyFilter,
type DateFilter,
type FilesFilter,
type FloatFilter,
type MultiSelectFilter,
type PhonesFilter,
@@ -167,6 +168,27 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
);
}
case 'FILES':
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as FilesFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as FilesFilter,
},
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
);
}
case 'DATE': {
const itsARelativeDateFilter =
recordFilter.operand === RecordFilterOperand.IS_RELATIVE;
@@ -0,0 +1,227 @@
import { isMatchingFilesFilter } from '@/utils/filter/utils/isMatchingFilesFilter';
describe('isMatchingFilesFilter', () => {
describe('is filter', () => {
it('should return true when checking for NULL and value is null', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NULL' },
value: null,
}),
).toBe(true);
});
it('should return true when checking for NULL and value is empty array', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NULL' },
value: [],
}),
).toBe(true);
});
it('should return false when checking for NULL and value has files', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NULL' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(false);
});
it('should return true when checking for NOT_NULL and value has files', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NOT_NULL' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should return false when checking for NOT_NULL and value is null', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NOT_NULL' },
value: null,
}),
).toBe(false);
});
it('should return false when checking for NOT_NULL and value is empty array', () => {
expect(
isMatchingFilesFilter({
filesFilter: { is: 'NOT_NULL' },
value: [],
}),
).toBe(false);
});
});
describe('like filter', () => {
it('should match files when like pattern matches JSON representation', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%file.pdf%' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should not match when like pattern does not match', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%document.docx%' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(false);
});
it('should be case insensitive', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%FILE.PDF%' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match partial file names', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%myfile%' },
value: [
{
fileId: '1',
label: 'myfile.pdf',
url: 'http://example.com/myfile.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match when any file in array matches', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%report%' },
value: [
{
fileId: '1',
label: 'invoice.pdf',
url: 'http://example.com/invoice.pdf',
extension: 'pdf',
},
{
fileId: '2',
label: 'annual_report.pdf',
url: 'http://example.com/report.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match by file extension', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%pdf%' },
value: [
{
fileId: '1',
label: 'document',
url: 'http://example.com/doc.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match by URL', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%example.com%' },
value: [
{
fileId: '1',
label: 'file.pdf',
url: 'http://example.com/file.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should match by fileId', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%file-123%' },
value: [
{
fileId: 'file-123',
label: 'document.pdf',
url: 'http://example.com/doc.pdf',
extension: 'pdf',
},
],
}),
).toBe(true);
});
it('should not match when value is null', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%file%' },
value: null,
}),
).toBe(false);
});
it('should not match when value is empty array', () => {
expect(
isMatchingFilesFilter({
filesFilter: { like: '%file%' },
value: [],
}),
).toBe(false);
});
});
});
@@ -355,6 +355,7 @@ export const getEmptyRecordGqlOperationFilter = ({
],
};
break;
case 'FILES':
case 'RAW_JSON':
emptyRecordFilter = {
or: [
@@ -39,6 +39,8 @@ export const getFilterTypeFromFieldType = (
return 'ARRAY';
case FieldMetadataType.RAW_JSON:
return 'RAW_JSON';
case FieldMetadataType.FILES:
return 'FILES';
case FieldMetadataType.BOOLEAN:
return 'BOOLEAN';
case FieldMetadataType.TS_VECTOR:
@@ -0,0 +1,36 @@
import { type FilesFilter } from '@/types';
export const isMatchingFilesFilter = ({
filesFilter,
value,
}: {
filesFilter: FilesFilter;
value: Record<string, any> | null;
}) => {
switch (true) {
case filesFilter.like !== undefined: {
const escapedPattern = filesFilter.like.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
const regexPattern = escapedPattern.replace(/%/g, '.*');
const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'is');
const stringValue = JSON.stringify(value, null, 1);
return regexCaseInsensitive.test(stringValue);
}
case filesFilter.is !== undefined: {
if (filesFilter.is === 'NULL') {
return value === null || value.length === 0;
} else {
return value !== null && value.length > 0;
}
}
default: {
throw new Error(
`Unexpected value for files filter : ${JSON.stringify(filesFilter)}`,
);
}
}
};
@@ -108,6 +108,7 @@ export { isMatchingArrayFilter } from './filter/utils/isMatchingArrayFilter';
export { isMatchingBooleanFilter } from './filter/utils/isMatchingBooleanFilter';
export { isMatchingCurrencyFilter } from './filter/utils/isMatchingCurrencyFilter';
export { isMatchingDateFilter } from './filter/utils/isMatchingDateFilter';
export { isMatchingFilesFilter } from './filter/utils/isMatchingFilesFilter';
export { isMatchingFloatFilter } from './filter/utils/isMatchingFloatFilter';
export { isMatchingMultiSelectFilter } from './filter/utils/isMatchingMultiSelectFilter';
export { isMatchingRatingFilter } from './filter/utils/isMatchingRatingFilter';