[Requires "warm" cache flush (no immediate downtime before flush)] Migrate viewGroup.fieldMetadataId -> view.mainGroupByFieldMetadataId (1/3) (#16206)

In this PR (1/3)
- introduce view.mainGroupByFieldMetadataId as the new reference
determining which fieldMetadataId is used in a grouped view, in order to
deprecate viewGroup.fieldMetadataId which creates inconsistencies.
view.mainGroupByFieldMetadataId is now filled at every view creation,
though not in use yet.
- Introduce a command to backfill view.mainGroupByFieldMetadataId for
existing views + delete all viewGroup.fieldMetadataId with a
fieldMetadataId that is not view.mainGroupByFieldMetadataId. (It should
concern 37 active workspaces)
- Temporarily disable the option to change a grouped view's
fieldMetadataId as for now it creates inconsistencies. This feature can
be reintroduced when we have done the full migration.

In a next PR
- (2/3) use view.mainGroupByFieldMetadataId instead of
viewGroup.fieldMetadataId. In FE we may keep viewGroup.fieldMetadataId
as a state (TBD). View groups will now be created / deleted as a side
effect of view's mainGroupByFieldMetadataId update.
- (3/3) remove viewGroup.fieldMetadataId

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Marie
2025-12-02 16:31:07 +01:00
committed by GitHub
parent aa729a2a0a
commit 77409b6eb2
71 changed files with 484 additions and 99 deletions
+2 -1
View File
@@ -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
+29
View File
@@ -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.
@@ -689,6 +689,7 @@ export type CoreView = {
kanbanAggregateOperation?: Maybe<AggregateOperations>;
kanbanAggregateOperationFieldMetadataId?: Maybe<Scalars['UUID']>;
key?: Maybe<ViewKey>;
mainGroupByFieldMetadataId?: Maybe<Scalars['UUID']>;
name: Scalars['String'];
objectMetadataId: Scalars['UUID'];
openRecordIn: ViewOpenRecordIn;
@@ -980,6 +981,7 @@ export type CreateViewInput = {
kanbanAggregateOperation?: InputMaybe<AggregateOperations>;
kanbanAggregateOperationFieldMetadataId?: InputMaybe<Scalars['UUID']>;
key?: InputMaybe<ViewKey>;
mainGroupByFieldMetadataId?: InputMaybe<Scalars['UUID']>;
name: Scalars['String'];
objectMetadataId: Scalars['UUID'];
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
@@ -4429,6 +4431,7 @@ export type UpdateViewInput = {
isCompact?: InputMaybe<Scalars['Boolean']>;
kanbanAggregateOperation?: InputMaybe<AggregateOperations>;
kanbanAggregateOperationFieldMetadataId?: InputMaybe<Scalars['UUID']>;
mainGroupByFieldMetadataId?: InputMaybe<Scalars['UUID']>;
name?: InputMaybe<Scalars['String']>;
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
position?: InputMaybe<Scalars['Float']>;
@@ -689,6 +689,7 @@ export type CoreView = {
kanbanAggregateOperation?: Maybe<AggregateOperations>;
kanbanAggregateOperationFieldMetadataId?: Maybe<Scalars['UUID']>;
key?: Maybe<ViewKey>;
mainGroupByFieldMetadataId?: Maybe<Scalars['UUID']>;
name: Scalars['String'];
objectMetadataId: Scalars['UUID'];
openRecordIn: ViewOpenRecordIn;
@@ -963,6 +964,7 @@ export type CreateViewInput = {
kanbanAggregateOperation?: InputMaybe<AggregateOperations>;
kanbanAggregateOperationFieldMetadataId?: InputMaybe<Scalars['UUID']>;
key?: InputMaybe<ViewKey>;
mainGroupByFieldMetadataId?: InputMaybe<Scalars['UUID']>;
name: Scalars['String'];
objectMetadataId: Scalars['UUID'];
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
@@ -4258,6 +4260,7 @@ export type UpdateViewInput = {
isCompact?: InputMaybe<Scalars['Boolean']>;
kanbanAggregateOperation?: InputMaybe<AggregateOperations>;
kanbanAggregateOperationFieldMetadataId?: InputMaybe<Scalars['UUID']>;
mainGroupByFieldMetadataId?: InputMaybe<Scalars['UUID']>;
name?: InputMaybe<Scalars['String']>;
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
position?: InputMaybe<Scalars['Float']>;
@@ -113,13 +113,10 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => {
>
{currentView?.key !== 'INDEX' && (
<>
<SelectableListItem
itemId="GroupBy"
onEnter={() => onContentChange('recordGroupFields')}
>
<SelectableListItem itemId="GroupBy">
<MenuItem
focused={selectedItemId === 'GroupBy'}
onClick={() => onContentChange('recordGroupFields')}
disabled
LeftIcon={IconLayoutList}
text={t`Group by`}
contextualText={recordGroupFieldMetadata?.label}
@@ -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,
],
@@ -24,6 +24,7 @@ export const VIEW_FRAGMENT = gql`
openRecordIn
kanbanAggregateOperation
kanbanAggregateOperationFieldMetadataId
# mainGroupByFieldMetadataId
anyFieldFilterValue
calendarFieldMetadataId
calendarLayout
@@ -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({
@@ -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,
),
);
@@ -30,6 +30,7 @@ export type CoreViewWithRelations = {
'workspaceId' | 'createdAt' | 'updatedAt'
>[];
viewSorts: CoreViewSortEssential[];
mainGroupByFieldMetadataId?: string | null;
kanbanAggregateOperation?: AggregateOperations | null;
kanbanAggregateOperationFieldMetadataId?: string | null;
calendarFieldMetadataId?: string | null;
@@ -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;
@@ -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;
@@ -37,7 +37,7 @@ describe('mapRecordFilterGroupToViewFilterGroup', () => {
viewFields: [],
viewGroups: [],
viewSorts: [],
kanbanFieldMetadataId: '',
mainGroupByFieldMetadataId: '',
kanbanAggregateOperation: AggregateOperations.COUNT,
icon: '',
kanbanAggregateOperationFieldMetadataId: '',
@@ -38,7 +38,7 @@ export const convertCoreViewToView = (
convertCoreViewFilterGroupToViewFilterGroup,
),
viewSorts: coreView.viewSorts,
kanbanFieldMetadataId: '',
mainGroupByFieldMetadataId: coreView.mainGroupByFieldMetadataId ?? null,
kanbanAggregateOperation: coreView.kanbanAggregateOperation ?? null,
kanbanAggregateOperationFieldMetadataId:
coreView.kanbanAggregateOperationFieldMetadataId ?? null,
@@ -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,
@@ -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,
);
@@ -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 = () => {
<Select
label={t`Stages`}
fullWidth
value={viewPickerKanbanFieldMetadataId}
value={viewPickerMainGroupByFieldMetadataId}
onChange={(value) => {
setViewPickerIsDirty(true);
setViewPickerKanbanFieldMetadataId(value);
setViewPickerMainGroupByFieldMetadataId(value);
}}
options={
availableFieldsForKanban.length > 0
@@ -14,7 +14,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 { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { viewPickerSelectedIconComponentState } from '@/views/view-picker/states/viewPickerSelectedIconComponentState';
import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState';
@@ -36,8 +36,12 @@ export const ViewPickerContentEffect = () => {
);
const { viewPickerMode } = useViewPickerMode();
const [viewPickerKanbanFieldMetadataId, setViewPickerKanbanFieldMetadataId] =
useRecoilComponentState(viewPickerKanbanFieldMetadataIdComponentState);
const [
viewPickerMainGroupByFieldMetadataId,
setViewPickerMainGroupByFieldMetadataId,
] = useRecoilComponentState(
viewPickerMainGroupByFieldMetadataIdComponentState,
);
const [
viewPickerCalendarFieldMetadataId,
@@ -112,12 +116,12 @@ export const ViewPickerContentEffect = () => {
if (
isDefined(referenceView) &&
availableFieldsForKanban.length > 0 &&
viewPickerKanbanFieldMetadataId === ''
viewPickerMainGroupByFieldMetadataId === ''
) {
setViewPickerKanbanFieldMetadataId(
// TODO: replace with viewGroups.fieldMetadataId
referenceView.kanbanFieldMetadataId !== ''
? referenceView.kanbanFieldMetadataId
setViewPickerMainGroupByFieldMetadataId(
isDefined(referenceView.mainGroupByFieldMetadataId) &&
referenceView.mainGroupByFieldMetadataId !== ''
? referenceView.mainGroupByFieldMetadataId
: availableFieldsForKanban[0].id,
);
}
@@ -136,8 +140,8 @@ export const ViewPickerContentEffect = () => {
}, [
referenceView,
availableFieldsForKanban,
viewPickerKanbanFieldMetadataId,
setViewPickerKanbanFieldMetadataId,
viewPickerMainGroupByFieldMetadataId,
setViewPickerMainGroupByFieldMetadataId,
availableFieldsForCalendar,
viewPickerCalendarFieldMetadataId,
setViewPickerCalendarFieldMetadataId,
@@ -7,7 +7,7 @@ import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGet
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
import { viewPickerCalendarFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerCalendarFieldMetadataIdComponentState';
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 { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState';
import { useLingui } from '@lingui/react/macro';
import { Button } from 'twenty-ui/input';
@@ -24,8 +24,8 @@ export const ViewPickerCreateButton = () => {
const viewPickerIsPersisting = useRecoilComponentValue(
viewPickerIsPersistingComponentState,
);
const viewPickerKanbanFieldMetadataId = useRecoilComponentValue(
viewPickerKanbanFieldMetadataIdComponentState,
const viewPickerMainGroupByFieldMetadataId = useRecoilComponentValue(
viewPickerMainGroupByFieldMetadataIdComponentState,
);
const viewPickerCalendarFieldMetadataId = useRecoilComponentValue(
viewPickerCalendarFieldMetadataIdComponentState,
@@ -88,7 +88,7 @@ export const ViewPickerCreateButton = () => {
if (
viewPickerType !== ViewType.Kanban ||
viewPickerKanbanFieldMetadataId !== ''
viewPickerMainGroupByFieldMetadataId !== ''
) {
return (
<Button
@@ -101,7 +101,7 @@ export const ViewPickerCreateButton = () => {
disabled={
viewPickerIsPersisting ||
(viewPickerType === ViewType.Kanban &&
viewPickerKanbanFieldMetadataId === '') ||
viewPickerMainGroupByFieldMetadataId === '') ||
(viewPickerType === ViewType.Calendar &&
viewPickerCalendarFieldMetadataId === '')
}
@@ -5,7 +5,7 @@ import { useDeleteViewFromCurrentState } from '@/views/view-picker/hooks/useDele
import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban';
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
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 { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState';
import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input';
@@ -19,8 +19,8 @@ export const ViewPickerEditButton = () => {
const viewPickerIsPersisting = useRecoilComponentValue(
viewPickerIsPersistingComponentState,
);
const viewPickerKanbanFieldMetadataId = useRecoilComponentValue(
viewPickerKanbanFieldMetadataIdComponentState,
const viewPickerMainGroupByFieldMetadataId = useRecoilComponentValue(
viewPickerMainGroupByFieldMetadataIdComponentState,
);
const { createViewFromCurrentState } = useCreateViewFromCurrentState();
@@ -60,7 +60,7 @@ export const ViewPickerEditButton = () => {
if (
viewPickerType === ViewType.Table ||
viewPickerKanbanFieldMetadataId !== ''
viewPickerMainGroupByFieldMetadataId !== ''
) {
return (
<Button
@@ -73,7 +73,7 @@ export const ViewPickerEditButton = () => {
disabled={
viewPickerIsPersisting ||
(viewPickerType === ViewType.Kanban &&
viewPickerKanbanFieldMetadataId === '')
viewPickerMainGroupByFieldMetadataId === '')
}
/>
);
@@ -2,12 +2,13 @@ import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useChangeView } from '@/views/hooks/useChangeView';
import { useCreateViewFromCurrentView } from '@/views/hooks/useCreateViewFromCurrentView';
import { ViewType } from '@/views/types/ViewType';
import { useCloseAndResetViewPicker } from '@/views/view-picker/hooks/useCloseAndResetViewPicker';
import { viewPickerCalendarFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerCalendarFieldMetadataIdComponentState';
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 { viewPickerModeComponentState } from '@/views/view-picker/states/viewPickerModeComponentState';
import { viewPickerSelectedIconComponentState } from '@/views/view-picker/states/viewPickerSelectedIconComponentState';
import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState';
@@ -30,9 +31,9 @@ export const useCreateViewFromCurrentState = () => {
viewPickerTypeComponentState,
);
const viewPickerKanbanFieldMetadataIdCallbackState =
const viewPickerMainGroupByFieldMetadataIdCallbackState =
useRecoilComponentCallbackState(
viewPickerKanbanFieldMetadataIdComponentState,
viewPickerMainGroupByFieldMetadataIdComponentState,
);
const viewPickerCalendarFieldMetadataIdCallbackState =
@@ -71,14 +72,15 @@ export const useCreateViewFromCurrentState = () => {
viewPickerSelectedIconCallbackState,
);
const type = getSnapshotValue(snapshot, viewPickerTypeCallbackState);
const kanbanFieldMetadataId = getSnapshotValue(
const mainGroupByFieldMetadataId = getSnapshotValue(
snapshot,
viewPickerKanbanFieldMetadataIdCallbackState,
viewPickerMainGroupByFieldMetadataIdCallbackState,
);
const calendarFieldMetadataId = getSnapshotValue(
snapshot,
viewPickerCalendarFieldMetadataIdCallbackState,
);
const viewPickerMode = getSnapshotValue(
snapshot,
viewPickerModeCallbackState,
@@ -99,7 +101,8 @@ export const useCreateViewFromCurrentState = () => {
name,
icon: iconKey,
type,
kanbanFieldMetadataId,
mainGroupByFieldMetadataId:
type === ViewType.Kanban ? mainGroupByFieldMetadataId : null,
calendarFieldMetadataId,
visibility,
},
@@ -118,7 +121,7 @@ export const useCreateViewFromCurrentState = () => {
viewPickerInputNameCallbackState,
viewPickerIsDirtyCallbackState,
viewPickerIsPersistingCallbackState,
viewPickerKanbanFieldMetadataIdCallbackState,
viewPickerMainGroupByFieldMetadataIdCallbackState,
viewPickerCalendarFieldMetadataIdCallbackState,
viewPickerSelectedIconCallbackState,
viewPickerTypeCallbackState,
@@ -1,9 +1,9 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
export const viewPickerKanbanFieldMetadataIdComponentState =
export const viewPickerMainGroupByFieldMetadataIdComponentState =
createComponentState<string>({
key: 'viewPickerKanbanFieldMetadataIdComponentState',
key: 'viewPickerMainGroupByFieldMetadataIdComponentState',
defaultValue: '',
componentInstanceContext: ViewComponentInstanceContext,
});
@@ -28,7 +28,6 @@ export const mockedViewsData: View[] = [
type: ViewType.Table,
icon: 'IconSkyline',
key: ViewKey.Index,
kanbanFieldMetadataId: '',
kanbanAggregateOperation: AggregateOperations.COUNT,
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
@@ -49,7 +48,6 @@ export const mockedViewsData: View[] = [
type: ViewType.Table,
icon: 'IconPerson',
key: ViewKey.Index,
kanbanFieldMetadataId: '',
kanbanAggregateOperation: AggregateOperations.COUNT,
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
@@ -70,7 +68,6 @@ export const mockedViewsData: View[] = [
type: ViewType.Kanban,
icon: 'IconOpportunity',
key: ViewKey.Index,
kanbanFieldMetadataId: '',
kanbanAggregateOperation: AggregateOperations.COUNT,
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
@@ -91,7 +88,6 @@ export const mockedViewsData: View[] = [
type: ViewType.Table,
icon: 'IconSkyline',
key: null,
kanbanFieldMetadataId: '',
kanbanAggregateOperation: AggregateOperations.COUNT,
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
@@ -0,0 +1,210 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { In, IsNull, Not, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { MigrationCommandOptions } from 'src/database/commands/command-runners/migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: 'upgrade:1-13:backfill:view-main-group-by-field-metadata-id',
description:
'Backfill mainGroupByFieldMetadataId on views and clean up inconsistent viewGroups',
})
export class BackfillViewMainGroupByFieldMetadataIdCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
@InjectRepository(ViewGroupEntity)
private readonly viewGroupRepository: Repository<ViewGroupEntity>,
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
protected readonly dataSourceService: DataSourceService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
await this.backfillMainGroupByFieldMetadataId(workspaceId, options);
await this.cleanupInconsistentViewGroups(workspaceId, options);
}
private async backfillMainGroupByFieldMetadataId(
workspaceId: string,
options: MigrationCommandOptions,
): Promise<void> {
this.logger.log(
`Starting backfill of mainGroupByFieldMetadataId for workspace ${workspaceId}...`,
);
const viewsToBackfill = await this.viewRepository.find({
where: {
mainGroupByFieldMetadataId: IsNull(),
workspaceId,
},
select: ['id', 'workspaceId'],
});
this.logger.log(
`Found ${viewsToBackfill.length} views with null mainGroupByFieldMetadataId`,
);
let backfilledCount = 0;
let inconsistentViewsCount = 0;
for (const view of viewsToBackfill) {
const nonDeletedViewGroups = await this.viewGroupRepository.find({
where: {
viewId: view.id,
deletedAt: IsNull(),
workspaceId,
},
select: ['id', 'fieldMetadataId'],
});
if (nonDeletedViewGroups.length === 0) {
continue;
}
const uniqueFieldMetadataIds = [
...new Set(nonDeletedViewGroups.map((vg) => vg.fieldMetadataId)),
];
if (uniqueFieldMetadataIds.length === 1) {
const fieldMetadataId = uniqueFieldMetadataIds[0];
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would backfill view ${view.id} (workspace ${view.workspaceId}) with fieldMetadataId ${fieldMetadataId}`,
);
} else {
await this.viewRepository.update(
{ id: view.id, workspaceId },
{ mainGroupByFieldMetadataId: fieldMetadataId },
);
this.logger.log(
`Backfilled view ${view.id} (workspace ${view.workspaceId}) with fieldMetadataId ${fieldMetadataId}`,
);
}
backfilledCount++;
} else {
this.logger.error(
`Inconsistency detected for view ${view.id} (workspace ${view.workspaceId}): found ${uniqueFieldMetadataIds.length} different fieldMetadataIds`,
);
const fieldMetadataIdCounts = new Map<string, number>();
for (const vg of nonDeletedViewGroups) {
const count = fieldMetadataIdCounts.get(vg.fieldMetadataId) || 0;
fieldMetadataIdCounts.set(vg.fieldMetadataId, count + 1);
}
let mostNumerousFieldMetadataId = '';
let maxCount = 0;
for (const [
fieldMetadataId,
count,
] of fieldMetadataIdCounts.entries()) {
if (count > maxCount) {
maxCount = count;
mostNumerousFieldMetadataId = fieldMetadataId;
}
}
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would backfill view ${view.id} (workspace ${view.workspaceId}) with fieldMetadataId ${mostNumerousFieldMetadataId} (most numerous, found ${maxCount} occurrences)`,
);
} else {
await this.viewRepository.update(
{ id: view.id },
{ mainGroupByFieldMetadataId: mostNumerousFieldMetadataId },
);
this.logger.log(
`Backfilled view ${view.id} (workspace ${view.workspaceId}) with fieldMetadataId ${mostNumerousFieldMetadataId} (most numerous, found ${maxCount} occurrences)`,
);
}
backfilledCount++;
inconsistentViewsCount++;
}
}
this.logger.log(
`${options.dryRun ? '[DRY RUN] Would have ' : ''}Backfilled ${backfilledCount} views${inconsistentViewsCount > 0 ? ` (${inconsistentViewsCount} with inconsistencies)` : ''}`,
);
}
private async cleanupInconsistentViewGroups(
workspaceId: string,
options: MigrationCommandOptions,
): Promise<void> {
this.logger.log('Starting cleanup of inconsistent viewGroups...');
const viewsWithMainGroupBy = await this.viewRepository.find({
where: {
mainGroupByFieldMetadataId: Not(IsNull()),
workspaceId,
},
select: ['id', 'mainGroupByFieldMetadataId'],
});
this.logger.log(
`Found ${viewsWithMainGroupBy.length} views with mainGroupByFieldMetadataId set`,
);
let totalDeletedCount = 0;
for (const view of viewsWithMainGroupBy) {
if (!isDefined(view.mainGroupByFieldMetadataId)) {
continue;
}
const inconsistentViewGroups = await this.viewGroupRepository.find({
where: {
viewId: view.id,
fieldMetadataId: Not(view.mainGroupByFieldMetadataId),
workspaceId,
},
select: ['id'],
});
if (inconsistentViewGroups.length === 0) {
continue;
}
const viewGroupIds = inconsistentViewGroups.map((vg) => vg.id);
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would delete ${inconsistentViewGroups.length} viewGroups from view ${view.id}`,
);
} else {
await this.viewGroupRepository.delete({
id: In(viewGroupIds),
workspaceId,
});
this.logger.log(
`Deleted ${inconsistentViewGroups.length} viewGroups from view ${view.id}`,
);
}
totalDeletedCount += inconsistentViewGroups.length;
}
this.logger.log(
`${options.dryRun ? '[DRY RUN] Would have ' : ''}Deleted ${totalDeletedCount} inconsistent viewGroups`,
);
}
}
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillViewMainGroupByFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-backfill-view-main-group-by-field-metadata-id.command';
import { CleanEmptyStringNullInTextFieldsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-clean-empty-string-null-in-text-fields.command';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Module({
imports: [
@@ -13,10 +16,18 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
WorkspaceEntity,
ObjectMetadataEntity,
FieldMetadataEntity,
ViewEntity,
ViewGroupEntity,
]),
DataSourceModule,
],
providers: [CleanEmptyStringNullInTextFieldsCommand],
exports: [CleanEmptyStringNullInTextFieldsCommand],
providers: [
CleanEmptyStringNullInTextFieldsCommand,
BackfillViewMainGroupByFieldMetadataIdCommand,
],
exports: [
CleanEmptyStringNullInTextFieldsCommand,
BackfillViewMainGroupByFieldMetadataIdCommand,
],
})
export class V1_13_UpgradeVersionCommandModule {}
@@ -0,0 +1,25 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddMainGroupByFieldMetadataId1764680275312
implements MigrationInterface
{
name = 'AddMainGroupByFieldMetadataId1764680275312';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."view" ADD "mainGroupByFieldMetadataId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "core"."view" ADD CONSTRAINT "FK_d1fa625016e36ec6f79fb13e824" FOREIGN KEY ("mainGroupByFieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."view" DROP CONSTRAINT "FK_d1fa625016e36ec6f79fb13e824"`,
);
await queryRunner.query(
`ALTER TABLE "core"."view" DROP COLUMN "mainGroupByFieldMetadataId"`,
);
}
}
@@ -1443,6 +1443,9 @@ export const STANDARD_OBJECTS = {
universalIdentifier:
VIEW_STANDARD_FIELD_IDS.kanbanAggregateOperationFieldMetadataId,
},
mainGroupByFieldMetadataId: {
universalIdentifier: VIEW_STANDARD_FIELD_IDS.mainGroupByFieldMetadataId,
},
position: { universalIdentifier: VIEW_STANDARD_FIELD_IDS.position },
isCompact: { universalIdentifier: VIEW_STANDARD_FIELD_IDS.isCompact },
openRecordIn: {
@@ -1,7 +1,7 @@
import {
FieldMetadataType,
FieldMetadataSettings,
FieldMetadataOptions,
FieldMetadataSettings,
FieldMetadataType,
} from 'twenty-shared/types';
import {
Check,
@@ -212,4 +212,7 @@ export class FieldMetadataEntity<
@OneToMany(() => ViewEntity, (view) => view.calendarFieldMetadata)
calendarViews: Relation<ViewEntity[]>;
@OneToMany(() => ViewEntity, (view) => view.mainGroupByFieldMetadata)
mainGroupByFieldMetadataViews: Relation<ViewEntity[]>;
}
@@ -41,6 +41,10 @@ export const ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY = {
metadataName: 'fieldMetadata',
flatEntityForeignKeyAggregator: 'calendarViewIds',
},
mainGroupByFieldMetadataId: {
metadataName: 'fieldMetadata',
flatEntityForeignKeyAggregator: 'mainGroupByFieldMetadataViewIds',
},
objectMetadataId: {
metadataName: 'objectMetadata',
flatEntityForeignKeyAggregator: 'viewIds',
@@ -25,6 +25,7 @@ export const getFlatFieldMetadataMock = <T extends FieldMetadataType>(
kanbanAggregateOperationViewIds: [],
viewFieldIds: [],
createdAt,
mainGroupByFieldMetadataViewIds: [],
updatedAt: createdAt,
defaultValue: null,
options: null,
@@ -33,6 +33,7 @@ export const getRelationTargetFlatFieldMetadataMock = ({
return {
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
viewFilterIds: [],
viewGroupIds: [],
viewFieldIds: [],
@@ -120,6 +120,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"isUnique": null,
"kanbanAggregateOperationViewIds": [],
"label": "newFieldLabel",
"mainGroupByFieldMetadataViewIds": [],
"morphId": Any<String>,
"name": "newFieldPets",
"objectMetadataId": Any<String>,
@@ -156,6 +157,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"isUnique": null,
"kanbanAggregateOperationViewIds": [],
"label": "Pet",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "pet",
"objectMetadataId": Any<String>,
@@ -194,6 +196,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"isUnique": null,
"kanbanAggregateOperationViewIds": [],
"label": "newFieldLabel",
"mainGroupByFieldMetadataViewIds": [],
"morphId": Any<String>,
"name": "newFieldCompanies",
"objectMetadataId": Any<String>,
@@ -230,6 +233,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"isUnique": null,
"kanbanAggregateOperationViewIds": [],
"label": "Company",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "company",
"objectMetadataId": Any<String>,
@@ -68,6 +68,7 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceCachePro
'id',
'kanbanAggregateOperationFieldMetadataId',
'calendarFieldMetadataId',
'mainGroupByFieldMetadataId',
],
withDeleted: true,
}),
@@ -79,6 +80,7 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceCachePro
viewGroupsByFieldId,
calendarViewsByFieldId,
kanbanViewsByFieldId,
mainGroupByFieldMetadataViewsByFieldId,
] = (
[
{
@@ -101,6 +103,10 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceCachePro
entities: views,
foreignKey: 'kanbanAggregateOperationFieldMetadataId',
},
{
entities: views,
foreignKey: 'mainGroupByFieldMetadataId',
},
] as const
).map(regroupEntitiesByRelatedEntityId);
@@ -115,6 +121,9 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceCachePro
kanbanAggregateOperationViews:
kanbanViewsByFieldId.get(fieldMetadataEntity.id) || [],
calendarViews: calendarViewsByFieldId.get(fieldMetadataEntity.id) || [],
mainGroupByFieldMetadataViews:
mainGroupByFieldMetadataViewsByFieldId.get(fieldMetadataEntity.id) ||
[],
} as FieldMetadataEntity);
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
@@ -11,9 +11,10 @@ export const FIELD_METADATA_RELATION_PROPERTIES = [
'viewFields',
'application',
'viewFilters',
'viewGroups',
'kanbanAggregateOperationViews',
'calendarViews',
'mainGroupByFieldMetadataViews',
'viewGroups',
] as const satisfies (keyof FieldMetadataEntity)[];
export type FieldMetadataEntityRelationProperties =
@@ -24,7 +25,8 @@ export type FlatFieldMetadata<T extends FieldMetadataType = FieldMetadataType> =
universalIdentifier: string;
viewFieldIds: string[];
viewFilterIds: string[];
viewGroupIds: string[];
kanbanAggregateOperationViewIds: string[];
calendarViewIds: string[];
mainGroupByFieldMetadataViewIds: string[];
viewGroupIds: string[];
};
@@ -23,6 +23,9 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = <
kanbanAggregateOperationViewIds:
fieldMetadataEntity.kanbanAggregateOperationViews.map(({ id }) => id),
calendarViewIds: fieldMetadataEntity.calendarViews.map(({ id }) => id),
mainGroupByFieldMetadataViewIds:
fieldMetadataEntity.mainGroupByFieldMetadataViews?.map(({ id }) => id) ??
[],
viewGroupIds: fieldMetadataEntity.viewGroups.map(({ id }) => id),
viewFieldIds: fieldMetadataEntity.viewFields.map(({ id }) => id),
viewFilterIds: fieldMetadataEntity.viewFilters.map(({ id }) => id),
@@ -26,6 +26,7 @@ export const getDefaultFlatFieldMetadata = ({
return {
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
viewFieldIds: [],
description: createFieldInput.description ?? null,
id: fieldMetadataId,
@@ -1,5 +1,5 @@
import { isDefined } from 'class-validator';
import { type EnumFieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
@@ -63,7 +63,8 @@ export const recomputeViewGroupsOnFlatFieldMetadataOptionsUpdate = ({
const flatViewGroupsToUpdate = updatedFieldMetadataOptions.flatMap(
({ from: fromOption, to: toOption }) =>
flatViewGroups.flatMap((flatViewGroup) =>
flatViewGroup.fieldValue === fromOption.value
flatViewGroup.fieldValue === fromOption.value &&
flatViewGroup.fieldMetadataId === fromFlatFieldMetadata.id
? { ...flatViewGroup, fieldValue: toOption.value }
: [],
),
@@ -13,4 +13,5 @@ export const FLAT_VIEW_EDITABLE_PROPERTIES = [
'calendarLayout',
'calendarFieldMetadataId',
'visibility',
'mainGroupByFieldMetadataId',
] as const satisfies (keyof FlatView)[];
@@ -44,6 +44,8 @@ export const fromCreateViewInputToFlatViewToCreate = ({
kanbanAggregateOperation: createViewInput.kanbanAggregateOperation ?? null,
kanbanAggregateOperationFieldMetadataId:
createViewInput.kanbanAggregateOperationFieldMetadataId ?? null,
mainGroupByFieldMetadataId:
createViewInput.mainGroupByFieldMetadataId ?? null,
key: createViewInput.key ?? null,
openRecordIn: createViewInput.openRecordIn ?? ViewOpenRecordIn.SIDE_PANEL,
position: createViewInput.position ?? 0,
@@ -31,6 +31,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
isLabelSyncedWithName: false,
isUnique: true,
objectMetadataId,
@@ -68,6 +69,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
isLabelSyncedWithName: false,
isUnique: false,
objectMetadataId,
@@ -105,6 +107,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
isLabelSyncedWithName: false,
isUnique: false,
objectMetadataId,
@@ -142,6 +145,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
isLabelSyncedWithName: false,
isUnique: false,
objectMetadataId,
@@ -179,6 +183,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
isLabelSyncedWithName: false,
isUnique: false,
objectMetadataId,
@@ -216,6 +221,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
isLabelSyncedWithName: false,
isUnique: false,
objectMetadataId,
@@ -252,6 +258,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
isLabelSyncedWithName: false,
isUnique: false,
objectMetadataId,
@@ -284,6 +291,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
const searchVectorFieldId = v4();
const searchVectorField: FlatFieldMetadata<FieldMetadataType.TS_VECTOR> = {
type: FieldMetadataType.TS_VECTOR,
mainGroupByFieldMetadataViewIds: [],
viewFieldIds: [],
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
@@ -61,6 +61,7 @@ const generateSourceFlatFieldMetadata = ({
return {
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
kanbanAggregateOperationViewIds: [],
viewFilterIds: [],
viewFieldIds: [],
@@ -128,6 +129,7 @@ const generateTargetFlatFieldMetadata = ({
return {
morphId: null,
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
viewFieldIds: [],
kanbanAggregateOperationViewIds: [],
viewFilterIds: [],
@@ -69,7 +69,7 @@ export class ViewFilterService {
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
const validateAndBuildResult =
const buildAndRunResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
fromToAllFlatEntityMaps: {
@@ -92,13 +92,18 @@ export class ViewFilterService {
},
);
if (isDefined(validateAndBuildResult)) {
if (isDefined(buildAndRunResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
buildAndRunResult,
'Multiple validation errors occurred while creating view filter',
);
}
this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatViewFilterMaps'],
});
const { flatViewFilterMaps: recomputedExistingFlatViewFilterMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -92,6 +92,11 @@ export class CreateViewInput {
@Field(() => UUIDScalarType, { nullable: true })
calendarFieldMetadataId?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
mainGroupByFieldMetadataId?: string;
@IsOptional()
@IsEnum(ViewVisibility)
@Field(() => ViewVisibility, {
@@ -87,4 +87,9 @@ export class UpdateViewInput {
@IsEnum(ViewVisibility)
@Field(() => ViewVisibility, { nullable: true })
visibility?: ViewVisibility;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
mainGroupByFieldMetadataId?: string;
}
@@ -62,6 +62,9 @@ export class ViewDTO {
@Field(() => UUIDScalarType, { nullable: true })
kanbanAggregateOperationFieldMetadataId?: string | null;
@Field(() => UUIDScalarType, { nullable: true })
mainGroupByFieldMetadataId?: string | null;
@Field(() => UUIDScalarType, { nullable: true })
calendarFieldMetadataId?: string | null;
@@ -138,6 +138,20 @@ export class ViewEntity extends SyncableEntity implements Required<ViewEntity> {
@JoinColumn({ name: 'calendarFieldMetadataId' })
calendarFieldMetadata: Relation<FieldMetadataEntity>;
@Column({ nullable: true, type: 'uuid' })
mainGroupByFieldMetadataId: string | null;
@ManyToOne(
() => FieldMetadataEntity,
(fieldMetadata) => fieldMetadata.mainGroupByFieldMetadataViews,
{
onDelete: 'CASCADE',
nullable: true,
},
)
@JoinColumn({ name: 'mainGroupByFieldMetadataId' })
mainGroupByFieldMetadata: Relation<FieldMetadataEntity>;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@@ -151,6 +151,7 @@ describe('WorkspaceEntityManager', () => {
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
relationTargetFieldMetadataId: null,
relationTargetObjectMetadataId: null,
morphId: null,
@@ -94,6 +94,7 @@ describe('WorkspaceRepository', () => {
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
viewFieldIds: [],
mainGroupByFieldMetadataViewIds: [],
};
mockInternalContext = {
@@ -63,6 +63,7 @@ describe('getFieldMetadataIdToColumnNamesMap', () => {
viewGroupIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
applicationId: null,
}) as unknown as FlatFieldMetadata;
@@ -171,6 +171,7 @@ export const createCoreViews = async (
icon,
isCustom,
openRecordIn,
mainGroupByFieldMetadataId,
kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
applicationId,
@@ -191,6 +192,7 @@ export const createCoreViews = async (
: ViewOpenRecordIn.SIDE_PANEL,
kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
mainGroupByFieldMetadataId,
workspaceId,
anyFieldFilterValue: null,
visibility: ViewVisibility.WORKSPACE,
@@ -16,9 +16,9 @@ export interface ViewDefinition {
icon?: string;
isCustom?: boolean;
openRecordIn?: ViewOpenRecordInType;
kanbanFieldMetadataId?: string;
kanbanAggregateOperation?: AggregateOperations;
kanbanAggregateOperationFieldMetadataId?: string;
mainGroupByFieldMetadataId?: string;
calendarFieldMetadataId?: string;
calendarLayout?: string;
fields?: {
@@ -42,7 +42,6 @@ export const calendarEventsAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconCalendar',
kanbanFieldMetadataId: '',
calendarFieldMetadataId:
calendarEventObjectMetadata.fields.find(
(field) =>
@@ -43,7 +43,6 @@ export const companiesAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconList',
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -42,7 +42,6 @@ export const dashboardsAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconLayoutDashboard',
kanbanFieldMetadataId: '',
openRecordIn: ViewOpenRecordInType.RECORD_PAGE,
filters: [],
fields: [
@@ -42,7 +42,6 @@ export const messageThreadsAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconList',
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -42,7 +42,6 @@ export const messagesAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconList',
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -41,7 +41,6 @@ export const notesAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconNotes',
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -39,7 +39,6 @@ export const opportunitiesAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconList',
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -29,6 +29,11 @@ export const opportunitiesByStageView = ({
const viewUniversalIdentifier =
STANDARD_OBJECTS.opportunity.views.byStage.universalIdentifier;
const stageFieldMetadataId =
opportunityObjectMetadata.fields.find(
(field) => field.standardId === OPPORTUNITY_STANDARD_FIELD_IDS.stage,
)?.id ?? '';
return {
id: v4(),
universalIdentifier: viewUniversalIdentifier,
@@ -39,16 +44,13 @@ export const opportunitiesByStageView = ({
key: null,
position: 2,
icon: 'IconLayoutKanban',
kanbanFieldMetadataId:
opportunityObjectMetadata.fields.find(
(field) => field.standardId === OPPORTUNITY_STANDARD_FIELD_IDS.stage,
)?.id ?? '',
kanbanAggregateOperation: AggregateOperations.MIN,
kanbanAggregateOperationFieldMetadataId:
opportunityObjectMetadata.fields.find(
(field) => field.standardId === OPPORTUNITY_STANDARD_FIELD_IDS.amount,
)?.id ?? '',
filters: [],
mainGroupByFieldMetadataId: stageFieldMetadataId,
fields: [
{
fieldMetadataId:
@@ -42,7 +42,6 @@ export const peopleAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconList',
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -41,7 +41,6 @@ export const tasksAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconList',
kanbanFieldMetadataId: '',
filters: [] /* [
{
fieldMetadataId:
@@ -42,7 +42,10 @@ export const tasksAssignedToMeView = ({
key: null,
position: 2,
icon: 'IconUserCircle',
kanbanFieldMetadataId: '',
mainGroupByFieldMetadataId:
taskObjectMetadata.fields.find(
(field) => field.standardId === TASK_STANDARD_FIELD_IDS.status,
)?.id ?? '',
filters: [
{
fieldMetadataId:
@@ -41,7 +41,7 @@ export const tasksByStatusView = ({
key: null,
position: 1,
icon: 'IconLayoutKanban',
kanbanFieldMetadataId:
mainGroupByFieldMetadataId:
taskObjectMetadata.fields.find(
(field) => field.standardId === TASK_STANDARD_FIELD_IDS.status,
)?.id ?? '',
@@ -44,7 +44,6 @@ export const timelineActivitiesAllView = ({
position: 0,
icon: 'IconList',
openRecordIn: ViewOpenRecordInType.RECORD_PAGE,
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -40,7 +40,6 @@ export const workflowRunsAllView = ({
position: 0,
icon: 'IconHistoryToggle',
openRecordIn: ViewOpenRecordInType.RECORD_PAGE,
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -46,7 +46,6 @@ export const workflowVersionsAllView = ({
position: 0,
icon: 'IconVersions',
openRecordIn: ViewOpenRecordInType.RECORD_PAGE,
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -43,7 +43,6 @@ export const workflowsAllView = ({
position: 0,
icon: 'IconSettingsAutomation',
openRecordIn: ViewOpenRecordInType.RECORD_PAGE,
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -45,7 +45,6 @@ export const workspaceMembersAllView = ({
key: 'INDEX',
position: 0,
icon: 'IconList',
kanbanFieldMetadataId: '',
filters: [],
fields: [
{
@@ -21,6 +21,7 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a crea
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"label": "flat field metadata label",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "flatFieldMetadataName",
"objectMetadataId": "object-metadata-id-1",
@@ -85,6 +86,7 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a dele
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"label": "flat field metadata label",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "flatFieldMetadataName",
"objectMetadataId": "object-metadata-id-1",
@@ -167,6 +169,7 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"label": "flat field metadata label",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "flatFieldMetadataName",
"objectMetadataId": "object-metadata-id-1",
@@ -213,6 +216,7 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create
"isUnique": false,
"kanbanAggregateOperationViewIds": [],
"label": "flat field metadata label",
"mainGroupByFieldMetadataViewIds": [],
"morphId": null,
"name": "flatFieldMetadataName",
"objectMetadataId": "object-metadata-id-1",
@@ -416,6 +416,7 @@ export const VIEW_STANDARD_FIELD_IDS = {
kanbanAggregateOperation: '20202020-8da2-45de-a731-61bed84b17a8',
kanbanAggregateOperationFieldMetadataId:
'20202020-b1b3-4bf3-85e4-dc7d58aa9b02',
mainGroupByFieldMetadataId: '20202020-f782-4f89-96eb-083a02cca887',
position: '20202020-e9db-4303-b271-e8250c450172',
isCompact: '20202020-674e-4314-994d-05754ea7b22b',
openRecordIn: '20202020-086d-4eef-9f03-56c6392eacb8',
@@ -21,6 +21,7 @@ export const getMockFieldMetadataEntity = <
): FieldMetadataEntity => {
return {
calendarViews: [],
mainGroupByFieldMetadataViews: [],
viewFilters: [],
viewFields: [],
viewGroups: [],