diff --git a/.cursor/rules/README.mdc b/.cursor/rules/README.mdc index ab0e887fb7..fe1e9e282c 100644 --- a/.cursor/rules/README.mdc +++ b/.cursor/rules/README.mdc @@ -12,6 +12,7 @@ This directory contains Twenty's development guidelines and best practices in th ### Core Guidelines - **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied) - **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files) +- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files) ### Code Quality - **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files) @@ -40,7 +41,7 @@ You can manually reference any rule using the `@ruleName` syntax: - `@testing-guidelines` - Get testing recommendations ### Rule Types Used -- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc) +- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc) - **Auto Attached** - Loaded when matching file patterns are referenced - **Agent Requested** - Available for AI to include when relevant - **Manual** - Only included when explicitly mentioned diff --git a/.cursor/rules/server-migrations.mdc b/.cursor/rules/server-migrations.mdc new file mode 100644 index 0000000000..6855d1a34b --- /dev/null +++ b/.cursor/rules/server-migrations.mdc @@ -0,0 +1,29 @@ +--- +description: Guidelines for generating and managing TypeORM migrations in twenty-server +globs: [ + "packages/twenty-server/src/**/*.entity.ts", + "packages/twenty-server/src/database/typeorm/**/*.ts" +] +alwaysApply: false +--- + +## Server Migrations (twenty-server) + +- **When changing an entity, always generate a migration** + - If you modify a `*.entity.ts` file in `packages/twenty-server/src`, you **must** generate a corresponding TypeORM migration instead of manually editing the database schema. + - Use the Nx + TypeORM command from the project root: + + ```bash + npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts + ``` + + - Replace `[name]` with a descriptive, kebab-case migration name that reflects the change (for example, `add-agent-turn-evaluation`). + +- **Prefer generated migrations over manual edits** + - Let TypeORM infer schema changes from the updated entities; only adjust the generated migration file manually if absolutely necessary (for example, for data backfills or complex constraints). + - Keep schema changes (DDL) in these generated migrations and avoid mixing in heavy data migrations unless there is a strong reason and clear comments. + +- **Keep migrations consistent and reversible** + - Ensure the generated migration includes both `up` and `down` logic that correctly applies and reverts the entity change when possible. + - Do not delete or rewrite existing, committed migrations unless you are explicitly working on a pre-release branch where history rewrites are allowed by team conventions. + diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index de06c2c92f..d263746e76 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -689,6 +689,7 @@ export type CoreView = { kanbanAggregateOperation?: Maybe; kanbanAggregateOperationFieldMetadataId?: Maybe; key?: Maybe; + mainGroupByFieldMetadataId?: Maybe; name: Scalars['String']; objectMetadataId: Scalars['UUID']; openRecordIn: ViewOpenRecordIn; @@ -980,6 +981,7 @@ export type CreateViewInput = { kanbanAggregateOperation?: InputMaybe; kanbanAggregateOperationFieldMetadataId?: InputMaybe; key?: InputMaybe; + mainGroupByFieldMetadataId?: InputMaybe; name: Scalars['String']; objectMetadataId: Scalars['UUID']; openRecordIn?: InputMaybe; @@ -4429,6 +4431,7 @@ export type UpdateViewInput = { isCompact?: InputMaybe; kanbanAggregateOperation?: InputMaybe; kanbanAggregateOperationFieldMetadataId?: InputMaybe; + mainGroupByFieldMetadataId?: InputMaybe; name?: InputMaybe; openRecordIn?: InputMaybe; position?: InputMaybe; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 4c2044b2fb..abb0ab33bc 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -689,6 +689,7 @@ export type CoreView = { kanbanAggregateOperation?: Maybe; kanbanAggregateOperationFieldMetadataId?: Maybe; key?: Maybe; + mainGroupByFieldMetadataId?: Maybe; name: Scalars['String']; objectMetadataId: Scalars['UUID']; openRecordIn: ViewOpenRecordIn; @@ -963,6 +964,7 @@ export type CreateViewInput = { kanbanAggregateOperation?: InputMaybe; kanbanAggregateOperationFieldMetadataId?: InputMaybe; key?: InputMaybe; + mainGroupByFieldMetadataId?: InputMaybe; name: Scalars['String']; objectMetadataId: Scalars['UUID']; openRecordIn?: InputMaybe; @@ -4258,6 +4260,7 @@ export type UpdateViewInput = { isCompact?: InputMaybe; kanbanAggregateOperation?: InputMaybe; kanbanAggregateOperationFieldMetadataId?: InputMaybe; + mainGroupByFieldMetadataId?: InputMaybe; name?: InputMaybe; openRecordIn?: InputMaybe; position?: InputMaybe; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx index b65f13d01c..23c2c5b607 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx @@ -113,13 +113,10 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => { > {currentView?.key !== 'INDEX' && ( <> - onContentChange('recordGroupFields')} - > + onContentChange('recordGroupFields')} + disabled LeftIcon={IconLayoutList} text={t`Group by`} contextualText={recordGroupFieldMetadata?.label} diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts index 6c510530a2..cb903ed37d 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts @@ -3,6 +3,7 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { usePersistView } from '@/views/hooks/internal/usePersistView'; import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroup'; import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState'; import { type ViewGroup } from '@/views/types/ViewGroup'; @@ -23,6 +24,8 @@ export const useHandleRecordGroupField = () => { const { setRecordGroupsFromViewGroups } = useSetRecordGroups(); + const { updateView } = usePersistView(); + const handleRecordGroupFieldChange = useRecoilCallback( ({ snapshot }) => async (fieldMetadataItem: FieldMetadataItem) => { @@ -34,7 +37,7 @@ export const useHandleRecordGroupField = () => { return; } - const view = await getViewFromPrefetchState(currentViewId); + const view = getViewFromPrefetchState(currentViewId); if (isUndefinedOrNull(view)) { return; @@ -47,6 +50,13 @@ export const useHandleRecordGroupField = () => { return; } + await updateView({ + id: view.id, + input: { + mainGroupByFieldMetadataId: fieldMetadataItem.id, + }, + }); + const existingGroupKeys = new Set( view.viewGroups.map( (group) => `${group.fieldMetadataId}:${group.fieldValue}`, @@ -124,10 +134,11 @@ export const useHandleRecordGroupField = () => { } }, [ - objectMetadataItem, currentViewIdCallbackState, getViewFromPrefetchState, + updateView, setRecordGroupsFromViewGroups, + objectMetadataItem, createViewGroups, deleteViewGroups, ], diff --git a/packages/twenty-front/src/modules/views/graphql/fragments/viewFragment.ts b/packages/twenty-front/src/modules/views/graphql/fragments/viewFragment.ts index 682ecee07f..f4a48510d8 100644 --- a/packages/twenty-front/src/modules/views/graphql/fragments/viewFragment.ts +++ b/packages/twenty-front/src/modules/views/graphql/fragments/viewFragment.ts @@ -24,6 +24,7 @@ export const VIEW_FRAGMENT = gql` openRecordIn kanbanAggregateOperation kanbanAggregateOperationFieldMetadataId + # mainGroupByFieldMetadataId anyFieldFilterValue calendarFieldMetadataId calendarLayout diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts index abe7869444..a49740db2c 100644 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts @@ -77,7 +77,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { id, name, icon, - kanbanFieldMetadataId, + mainGroupByFieldMetadataId, calendarFieldMetadataId, type, visibility, @@ -87,7 +87,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { | 'id' | 'name' | 'icon' - | 'kanbanFieldMetadataId' + | 'mainGroupByFieldMetadataId' | 'calendarFieldMetadataId' | 'type' | 'visibility' @@ -133,6 +133,9 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { shouldCopyFiltersAndSortsAndAggregate ? sourceView.kanbanAggregateOperationFieldMetadataId : undefined, + mainGroupByFieldMetadataId: shouldCopyFiltersAndSortsAndAggregate + ? sourceView.mainGroupByFieldMetadataId + : mainGroupByFieldMetadataId, type: convertViewTypeToCore(viewType), objectMetadataId: sourceView.objectMetadataId, openRecordIn: convertViewOpenRecordInToCore( @@ -179,19 +182,19 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { } if (type === ViewType.Kanban) { - if (!isDefined(kanbanFieldMetadataId)) { + if (!isDefined(mainGroupByFieldMetadataId)) { throw new Error('Kanban view must have a kanban field'); } const viewGroupsToCreate = objectMetadataItem.fields - ?.find((field) => field.id === kanbanFieldMetadataId) + ?.find((field) => field.id === mainGroupByFieldMetadataId) ?.options?.map( (option, index) => ({ id: v4(), __typename: 'ViewGroup', - fieldMetadataId: kanbanFieldMetadataId, + fieldMetadataId: mainGroupByFieldMetadataId, fieldValue: option.value, isVisible: true, position: index, @@ -204,7 +207,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { fieldValue: '', position: viewGroupsToCreate.length, isVisible: true, - fieldMetadataId: kanbanFieldMetadataId, + fieldMetadataId: mainGroupByFieldMetadataId, } satisfies ViewGroup); const groupResult = await createViewGroups({ diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts index b28c27718c..740b70a21e 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts @@ -45,7 +45,9 @@ export const useSaveCurrentViewGroups = () => { const existingField = currentViewGroups.find( (currentViewGroup) => - currentViewGroup.fieldValue === viewGroupToSave.fieldValue, + currentViewGroup.fieldValue === viewGroupToSave.fieldValue && + currentViewGroup.fieldMetadataId === + viewGroupToSave.fieldMetadataId, ); if (isUndefinedOrNull(existingField)) { @@ -116,7 +118,9 @@ export const useSaveCurrentViewGroups = () => { .map((viewGroupToSave) => { const existingField = currentViewGroups.find( (currentViewGroup) => - currentViewGroup.fieldValue === viewGroupToSave.fieldValue, + currentViewGroup.fieldValue === viewGroupToSave.fieldValue && + currentViewGroup.fieldMetadataId === + viewGroupToSave.fieldMetadataId, ); if (isUndefinedOrNull(existingField)) { @@ -156,7 +160,9 @@ export const useSaveCurrentViewGroups = () => { (viewFieldToSave) => !currentViewGroups.some( (currentViewGroup) => - currentViewGroup.fieldValue === viewFieldToSave.fieldValue, + currentViewGroup.fieldValue === viewFieldToSave.fieldValue && + currentViewGroup.fieldMetadataId === + viewFieldToSave.fieldMetadataId, ), ); diff --git a/packages/twenty-front/src/modules/views/types/CoreViewWithRelations.ts b/packages/twenty-front/src/modules/views/types/CoreViewWithRelations.ts index 644a1824f1..79a3911781 100644 --- a/packages/twenty-front/src/modules/views/types/CoreViewWithRelations.ts +++ b/packages/twenty-front/src/modules/views/types/CoreViewWithRelations.ts @@ -30,6 +30,7 @@ export type CoreViewWithRelations = { 'workspaceId' | 'createdAt' | 'updatedAt' >[]; viewSorts: CoreViewSortEssential[]; + mainGroupByFieldMetadataId?: string | null; kanbanAggregateOperation?: AggregateOperations | null; kanbanAggregateOperationFieldMetadataId?: string | null; calendarFieldMetadataId?: string | null; diff --git a/packages/twenty-front/src/modules/views/types/GraphQLView.ts b/packages/twenty-front/src/modules/views/types/GraphQLView.ts index 4736f98f9b..dda4af6cd9 100644 --- a/packages/twenty-front/src/modules/views/types/GraphQLView.ts +++ b/packages/twenty-front/src/modules/views/types/GraphQLView.ts @@ -15,10 +15,7 @@ export type GraphQLView = { name: string; type: ViewType; key: ViewKey | null; - /** - * @deprecated Use `viewGroups.fieldMetadataId` instead. - */ - kanbanFieldMetadataId: string; + mainGroupByFieldMetadataId?: string | null; kanbanAggregateOperation?: AggregateOperations | null; kanbanAggregateOperationFieldMetadataId?: string | null; objectMetadataId: string; diff --git a/packages/twenty-front/src/modules/views/types/View.ts b/packages/twenty-front/src/modules/views/types/View.ts index b95b5c3a64..250e785452 100644 --- a/packages/twenty-front/src/modules/views/types/View.ts +++ b/packages/twenty-front/src/modules/views/types/View.ts @@ -22,12 +22,9 @@ export type View = { viewFilters: ViewFilter[]; viewFilterGroups?: ViewFilterGroup[]; viewSorts: CoreViewSortEssential[]; - /** - * @deprecated Use `viewGroups.fieldMetadataId` instead. - */ - kanbanFieldMetadataId: string; kanbanAggregateOperation: AggregateOperations | null; kanbanAggregateOperationFieldMetadataId: string | null; + mainGroupByFieldMetadataId?: string | null; calendarFieldMetadataId?: string | null; calendarLayout?: ViewCalendarLayout | null; position: number; diff --git a/packages/twenty-front/src/modules/views/utils/__tests__/mapRecordFilterGroupToViewFilterGroup.test.ts b/packages/twenty-front/src/modules/views/utils/__tests__/mapRecordFilterGroupToViewFilterGroup.test.ts index 23e35cd265..ac6e026456 100644 --- a/packages/twenty-front/src/modules/views/utils/__tests__/mapRecordFilterGroupToViewFilterGroup.test.ts +++ b/packages/twenty-front/src/modules/views/utils/__tests__/mapRecordFilterGroupToViewFilterGroup.test.ts @@ -37,7 +37,7 @@ describe('mapRecordFilterGroupToViewFilterGroup', () => { viewFields: [], viewGroups: [], viewSorts: [], - kanbanFieldMetadataId: '', + mainGroupByFieldMetadataId: '', kanbanAggregateOperation: AggregateOperations.COUNT, icon: '', kanbanAggregateOperationFieldMetadataId: '', diff --git a/packages/twenty-front/src/modules/views/utils/convertCoreViewToView.ts b/packages/twenty-front/src/modules/views/utils/convertCoreViewToView.ts index ee0c99956a..4971d32c23 100644 --- a/packages/twenty-front/src/modules/views/utils/convertCoreViewToView.ts +++ b/packages/twenty-front/src/modules/views/utils/convertCoreViewToView.ts @@ -38,7 +38,7 @@ export const convertCoreViewToView = ( convertCoreViewFilterGroupToViewFilterGroup, ), viewSorts: coreView.viewSorts, - kanbanFieldMetadataId: '', + mainGroupByFieldMetadataId: coreView.mainGroupByFieldMetadataId ?? null, kanbanAggregateOperation: coreView.kanbanAggregateOperation ?? null, kanbanAggregateOperationFieldMetadataId: coreView.kanbanAggregateOperationFieldMetadataId ?? null, diff --git a/packages/twenty-front/src/modules/views/utils/getObjectMetadataItemViews.ts b/packages/twenty-front/src/modules/views/utils/getObjectMetadataItemViews.ts index ac125abce1..08c87089a1 100644 --- a/packages/twenty-front/src/modules/views/utils/getObjectMetadataItemViews.ts +++ b/packages/twenty-front/src/modules/views/utils/getObjectMetadataItemViews.ts @@ -16,7 +16,7 @@ export const getObjectMetadataItemViews = ( key: view.key, position: view.position, objectMetadataId: view.objectMetadataId, - kanbanFieldMetadataId: view.kanbanFieldMetadataId, + mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId, kanbanAggregateOperation: view.kanbanAggregateOperation, kanbanAggregateOperationFieldMetadataId: view.kanbanAggregateOperationFieldMetadataId, diff --git a/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts index b8a8cea0a1..61b055a9c6 100644 --- a/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts +++ b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts @@ -4,8 +4,8 @@ import { RecordGroupDefinitionType, } from '@/object-record/record-group/types/RecordGroupDefinition'; import { type ViewGroup } from '@/views/types/ViewGroup'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; import { isDefined } from 'twenty-shared/utils'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; export const mapViewGroupsToRecordGroupDefinitions = ({ objectMetadataItem, @@ -36,6 +36,10 @@ export const mapViewGroupsToRecordGroupDefinitions = ({ const recordGroupDefinitionsFromViewGroups = viewGroups .map((viewGroup) => { + if (viewGroup.fieldMetadataId !== selectFieldMetadataItem.id) { + return null; + } + const selectedOption = selectFieldMetadataItem.options?.find( (option) => option.value === viewGroup.fieldValue, ); diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx index 75bd359060..5465f39b4b 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx @@ -33,7 +33,7 @@ import { viewPickerCalendarFieldMetadataIdComponentState } from '@/views/view-pi import { viewPickerInputNameComponentState } from '@/views/view-picker/states/viewPickerInputNameComponentState'; import { viewPickerIsDirtyComponentState } from '@/views/view-picker/states/viewPickerIsDirtyComponentState'; import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState'; -import { viewPickerKanbanFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerKanbanFieldMetadataIdComponentState'; +import { viewPickerMainGroupByFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerMainGroupByFieldMetadataIdComponentState'; import { viewPickerSelectedIconComponentState } from '@/views/view-picker/states/viewPickerSelectedIconComponentState'; import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState'; import { useLingui } from '@lingui/react/macro'; @@ -74,8 +74,12 @@ export const ViewPickerContentCreateMode = () => { viewPickerIsDirtyComponentState, ); - const [viewPickerKanbanFieldMetadataId, setViewPickerKanbanFieldMetadataId] = - useRecoilComponentState(viewPickerKanbanFieldMetadataIdComponentState); + const [ + viewPickerMainGroupByFieldMetadataId, + setViewPickerMainGroupByFieldMetadataId, + ] = useRecoilComponentState( + viewPickerMainGroupByFieldMetadataIdComponentState, + ); const [ viewPickerCalendarFieldMetadataId, @@ -188,10 +192,10 @@ export const ViewPickerContentCreateMode = () => {