Refactor field definition and column definition manipulation with record field (#13992)

This PR introduces the usage of record fields in the manipulation of
table columns and board fields.

Since all of the actual system relies on states like tableColumns,
recordIndexFieldDefinitions and the likes, it was required to implement
temporary utils that modifies those states in parallel of the new
generic currentRecordFields, to keep a working product.

With this PR though, currentRecordFields becomes the single source of
truth that gets saved to DB, and the remaining work is just to make the
switch with this new state on all components that are plugged to
tableColumns and the like.

This will be done in another PR.
This commit is contained in:
Lucas Bordeau
2025-08-20 12:03:12 +02:00
committed by GitHub
parent 3f6866da20
commit 00478152e0
31 changed files with 867 additions and 459 deletions
@@ -1,119 +0,0 @@
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
jest.mock('@/views/hooks/useSaveCurrentViewFields', () => ({
useSaveCurrentViewFields: jest.fn(() => ({
saveViewFields: jest.fn(),
})),
}));
jest.mock('@/views/hooks/useUpdateCurrentView', () => ({
useUpdateCurrentView: jest.fn(() => ({
updateCurrentView: jest.fn(),
})),
}));
const objectNameSingular = 'company';
describe('useObjectOptionsForBoard', () => {
const mockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular,
);
if (!mockObjectMetadataItem) {
throw new Error('Mock object metadata item not found');
}
const mockFieldMetadataItem1 = mockObjectMetadataItem.fields.find(
(field) => field.name === 'name',
);
if (!mockFieldMetadataItem1) {
throw new Error('Mock field metadata item not found for "name"');
}
const mockFieldMetadataItem2 = mockObjectMetadataItem.fields.find(
(field) => field.name === 'createdAt',
);
if (!mockFieldMetadataItem2) {
throw new Error('Mock field metadata item not found for "createdAt"');
}
const initialRecoilState = [
{
fieldMetadataId: mockFieldMetadataItem1.id,
isVisible: true,
position: 0,
},
{
fieldMetadataId: mockFieldMetadataItem2.id,
isVisible: true,
position: 1,
},
];
const renderWithRecoil = () =>
renderHook(
() =>
useObjectOptionsForBoard({
objectNameSingular,
recordBoardId: 'boardId',
viewBarId: 'viewBarId',
}),
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(
recordIndexFieldDefinitionsState,
initialRecoilState as any,
);
},
componentInstanceId: 'test',
contextStoreCurrentObjectMetadataNameSingular: objectNameSingular,
}),
},
);
it('reorders fields correctly', () => {
const { result } = renderWithRecoil();
const dropResult: DropResult = {
source: { droppableId: 'droppable', index: 1 },
destination: { droppableId: 'droppable', index: 2 },
draggableId: mockFieldMetadataItem1.id,
type: 'TYPE',
mode: 'FLUID',
reason: 'DROP',
combine: null,
};
const responderProvided: ResponderProvided = {
announce: jest.fn(),
};
act(() => {
result.current.handleReorderBoardFields(dropResult, responderProvided);
});
expect(result.current.visibleBoardFields).toEqual([
{
fieldMetadataId: mockFieldMetadataItem2.id,
isVisible: true,
position: 0,
},
{
fieldMetadataId: mockFieldMetadataItem1.id,
isVisible: true,
position: 1,
},
]);
});
});
@@ -5,17 +5,25 @@ import { useRecoilState } from 'recoil';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState';
import { useReorderVisibleRecordFields } from '@/object-record/record-field/hooks/useReorderVisibleRecordFields';
import { useUpdateRecordField } from '@/object-record/record-field/hooks/useUpdateRecordField';
import { useUpsertRecordField } from '@/object-record/record-field/hooks/useUpsertRecordField';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { type ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { mapBoardFieldDefinitionsToViewFields } from '@/views/utils/mapBoardFieldDefinitionsToViewFields';
import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField';
import { produce } from 'immer';
import { findByProperty, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { sortByProperty } from '~/utils/array/sortByProperty';
type useObjectOptionsForBoardParams = {
objectNameSingular: string;
@@ -71,6 +79,9 @@ export const useObjectOptionsForBoard = ({
[recordIndexFieldDefinitions],
);
const { reorderVisibleRecordFields } =
useReorderVisibleRecordFields(recordBoardId);
const hiddenBoardFields = useMemo(
() =>
availableColumnDefinitions
@@ -97,24 +108,46 @@ export const useObjectOptionsForBoard = ({
return;
}
const reorderedVisibleBoardFields = moveArrayItem(visibleBoardFields, {
const updatedRecordField = reorderVisibleRecordFields({
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
if (isDeeplyEqual(visibleBoardFields, reorderedVisibleBoardFields))
return;
saveViewFields([mapRecordFieldToViewField(updatedRecordField)]);
const updatedFields = [...reorderedVisibleBoardFields].map(
(field, index) => ({ ...field, position: index }),
const modifiedRecordIndexFieldDefinitions = produce(
recordIndexFieldDefinitions,
(draftRecordIndexFieldDefinitions) => {
const indexToModify = draftRecordIndexFieldDefinitions.findIndex(
(recordIndexFieldDefinitionToModify) =>
recordIndexFieldDefinitionToModify.fieldMetadataId ===
updatedRecordField.fieldMetadataItemId,
);
draftRecordIndexFieldDefinitions[indexToModify].position =
updatedRecordField.position;
},
);
setRecordIndexFieldDefinitions(updatedFields);
saveViewFields(mapBoardFieldDefinitionsToViewFields(updatedFields));
// TODO: remove after refactor
setRecordIndexFieldDefinitions(modifiedRecordIndexFieldDefinitions);
},
[saveViewFields, setRecordIndexFieldDefinitions, visibleBoardFields],
[
saveViewFields,
setRecordIndexFieldDefinitions,
recordIndexFieldDefinitions,
reorderVisibleRecordFields,
],
);
const currentRecordFields = useRecoilComponentValue(
currentRecordFieldsComponentState,
recordBoardId,
);
const { updateRecordField } = useUpdateRecordField(recordBoardId);
const { upsertRecordField } = useUpsertRecordField(recordBoardId);
// Todo : this seems over complex and should at least be extracted to an util with unit test.
// Let's refactor this as we introduce the new viewBar
const handleBoardFieldVisibilityChange = useCallback(
@@ -124,59 +157,98 @@ export const useObjectOptionsForBoard = ({
'fieldMetadataId' | 'isVisible'
>,
) => {
const isNewViewField = !(
updatedFieldDefinition.fieldMetadataId in
recordIndexFieldDefinitionsByKey
const lastPosition = currentRecordFields.toSorted(
sortByProperty('position', 'desc'),
)[0].position;
const shouldShowFieldMetadataItem =
updatedFieldDefinition.isVisible === true;
const corresponingRecordField = currentRecordFields.find(
(recordFieldToFind) =>
recordFieldToFind.fieldMetadataItemId ===
updatedFieldDefinition.fieldMetadataId,
);
let updatedFieldsDefinitions: ColumnDefinition<FieldMetadata>[];
const noExistingRecordField = !isDefined(corresponingRecordField);
if (isNewViewField) {
const correspondingFieldDefinition = availableColumnDefinitions.find(
(availableColumnDefinition) =>
availableColumnDefinition.fieldMetadataId ===
updatedFieldDefinition.fieldMetadataId,
);
if (noExistingRecordField) {
const recordFieldToUpsert: RecordField = {
id: v4(),
fieldMetadataItemId: updatedFieldDefinition.fieldMetadataId,
size: 100,
isVisible: shouldShowFieldMetadataItem,
position: lastPosition + 1,
};
if (!correspondingFieldDefinition) return;
upsertRecordField(recordFieldToUpsert);
const lastVisibleBoardField =
visibleBoardFields[visibleBoardFields.length - 1];
saveViewFields([mapRecordFieldToViewField(recordFieldToUpsert)]);
updatedFieldsDefinitions = [
...recordIndexFieldDefinitions,
{
...correspondingFieldDefinition,
position: (lastVisibleBoardField?.position || 0) + 1,
isVisible: true,
const correspondingAvailableColumnDefinition =
availableColumnDefinitions.find(
findByProperty(
'fieldMetadataId',
updatedFieldDefinition.fieldMetadataId,
),
);
const modifiedRecordIndexFieldDefinitions = produce(
recordIndexFieldDefinitions,
(draftRecordIndexFieldDefinitions) => {
if (!isDefined(correspondingAvailableColumnDefinition)) {
throw new Error(
`correspondingAvailableColumnDefinition is not defined this should not happen.`,
);
}
draftRecordIndexFieldDefinitions.push({
...correspondingAvailableColumnDefinition,
fieldMetadataId: updatedFieldDefinition.fieldMetadataId,
isVisible: shouldShowFieldMetadataItem,
});
},
];
} else {
updatedFieldsDefinitions = recordIndexFieldDefinitions.map(
(existingFieldDefinition) =>
existingFieldDefinition.fieldMetadataId ===
updatedFieldDefinition.fieldMetadataId
? {
...existingFieldDefinition,
isVisible: !existingFieldDefinition.isVisible,
}
: existingFieldDefinition,
);
// TODO: remove after refactor
setRecordIndexFieldDefinitions(modifiedRecordIndexFieldDefinitions);
} else {
updateRecordField(updatedFieldDefinition.fieldMetadataId, {
isVisible: shouldShowFieldMetadataItem,
});
const updatedRecordField: RecordField = {
...corresponingRecordField,
isVisible: shouldShowFieldMetadataItem,
};
saveViewFields([mapRecordFieldToViewField(updatedRecordField)]);
const modifiedRecordIndexFieldDefinitions = produce(
recordIndexFieldDefinitions,
(draftRecordIndexFieldDefinitions) => {
const indexToModify = draftRecordIndexFieldDefinitions.findIndex(
(recordIndexFieldDefinitionToModify) =>
recordIndexFieldDefinitionToModify.fieldMetadataId ===
updatedRecordField.fieldMetadataItemId,
);
draftRecordIndexFieldDefinitions[indexToModify].isVisible =
shouldShowFieldMetadataItem;
},
);
// TODO: remove after refactor
setRecordIndexFieldDefinitions(modifiedRecordIndexFieldDefinitions);
}
setRecordIndexFieldDefinitions(updatedFieldsDefinitions);
saveViewFields(
mapBoardFieldDefinitionsToViewFields(updatedFieldsDefinitions),
);
},
[
recordIndexFieldDefinitionsByKey,
currentRecordFields,
updateRecordField,
upsertRecordField,
setRecordIndexFieldDefinitions,
saveViewFields,
availableColumnDefinitions,
visibleBoardFields,
recordIndexFieldDefinitions,
availableColumnDefinitions,
],
);
@@ -1,12 +1,19 @@
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { useCallback } from 'react';
import { useReorderRecordFields } from '@/object-record/record-field/hooks/useReorderRecordFields';
import { useFilterVisibleAndReadableRecordField } from '@/object-record/record-field/hooks/useFilterVisibleAndReadableRecordField';
import { useReorderVisibleRecordFields } from '@/object-record/record-field/hooks/useReorderVisibleRecordFields';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { useSetTableColumns } from '@/object-record/record-table/hooks/useSetTableColumns';
import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns';
import { hiddenTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields';
import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField';
import { produce } from 'immer';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { sortByProperty } from '~/utils/array/sortByProperty';
export const useObjectOptionsForTable = (
recordTableId: string,
@@ -16,19 +23,37 @@ export const useObjectOptionsForTable = (
hiddenTableColumnsComponentSelector,
recordTableId,
);
const visibleTableColumns = useRecoilComponentValue(
visibleTableColumnsComponentSelector,
const tableColumns = useRecoilComponentValue(
tableColumnsComponentState,
recordTableId,
);
const { handleColumnVisibilityChange, handleColumnReorder } = useTableColumns(
{ recordTableId, objectMetadataId },
const currentRecordFields = useRecoilComponentValue(
currentRecordFieldsComponentState,
recordTableId,
);
const { reorderRecordFields } = useReorderRecordFields();
const { filterVisibleAndReadableRecordField } =
useFilterVisibleAndReadableRecordField();
const handleReorderColumns: OnDragEndResponder = useCallback(
async (result) => {
const visibleRecordFields = currentRecordFields
.filter(filterVisibleAndReadableRecordField)
.toSorted(sortByProperty('position'));
const { handleColumnVisibilityChange } = useTableColumns({
recordTableId,
});
const { reorderVisibleRecordFields } =
useReorderVisibleRecordFields(recordTableId);
const { saveViewFields } = useSaveCurrentViewFields();
const { setTableColumns } = useSetTableColumns();
const handleReorderColumns: OnDragEndResponder = useRecoilCallback(
() => async (result) => {
if (
!result.destination ||
result.destination.index === 1 ||
@@ -37,25 +62,55 @@ export const useObjectOptionsForTable = (
return;
}
const reorderedFields = moveArrayItem(visibleTableColumns, {
const updatedRecordField = reorderVisibleRecordFields({
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
reorderRecordFields({
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
saveViewFields([mapRecordFieldToViewField(updatedRecordField)]);
handleColumnReorder(reorderedFields);
// TODO: remove this after refactor
const modifiedVisibleTableColumns = produce(
tableColumns,
(draftTableColumns) => {
const indexToModify = draftTableColumns.findIndex(
(tableColumnToFind) =>
tableColumnToFind.fieldMetadataId ===
updatedRecordField.fieldMetadataItemId,
);
if (isDefined(draftTableColumns[indexToModify])) {
draftTableColumns[indexToModify].position =
updatedRecordField.position;
} else {
throw new Error(
`Undefined draftTableColumns this should not happen`,
);
}
},
);
// TODO: remove after refactor
setTableColumns(
modifiedVisibleTableColumns,
recordTableId,
objectMetadataId,
);
},
[visibleTableColumns, handleColumnReorder, reorderRecordFields],
[
reorderVisibleRecordFields,
saveViewFields,
objectMetadataId,
recordTableId,
setTableColumns,
tableColumns,
],
);
return {
handleReorderColumns,
handleColumnVisibilityChange,
visibleTableColumns,
visibleTableColumns: visibleRecordFields,
hiddenTableColumns,
};
};