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:
@@ -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;
|
||||
|
||||
+227
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user