View field override (#18572)

## Context
This PR introduces overrides for view fields which will be useful for
page layout FIELDS widgets fields position/groups/visibility override +
restore logic.
This commit is contained in:
Weiko 2026-03-14 12:40:15 +01:00 committed by GitHub
parent 0b0ffcb8fa
commit 48172d60fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 864 additions and 114 deletions

File diff suppressed because one or more lines are too long

View file

@ -236,6 +236,7 @@ export const WithViewFieldGroups: Story = {
name: 'Contact Info',
position: 0,
isVisible: true,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
viewFields: [
{
@ -245,6 +246,7 @@ export const WithViewFieldGroups: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
{
@ -254,6 +256,7 @@ export const WithViewFieldGroups: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
{
@ -263,6 +266,7 @@ export const WithViewFieldGroups: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
],
@ -272,6 +276,7 @@ export const WithViewFieldGroups: Story = {
name: 'Business',
position: 1,
isVisible: true,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
viewFields: [
{
@ -281,6 +286,7 @@ export const WithViewFieldGroups: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
{
@ -290,6 +296,7 @@ export const WithViewFieldGroups: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
{
@ -299,6 +306,7 @@ export const WithViewFieldGroups: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
],
@ -393,6 +401,7 @@ export const WithInlineViewFields: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
{
@ -402,6 +411,7 @@ export const WithInlineViewFields: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
{
@ -411,6 +421,7 @@ export const WithInlineViewFields: Story = {
isVisible: true,
size: 200,
aggregateOperation: null,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
},
],
@ -501,6 +512,7 @@ export const Empty: Story = {
name: 'Empty Group',
position: 0,
isVisible: false,
isOverridden: false,
viewId: FIELDS_VIEW_ID,
viewFields: [],
},

View file

@ -9,6 +9,7 @@ export const VIEW_FIELD_FRAGMENT = gql`
position
size
aggregateOperation
isOverridden
createdAt
updatedAt
deletedAt

View file

@ -9,6 +9,7 @@ export const VIEW_FIELD_GROUP_FRAGMENT = gql`
position
isVisible
viewId
isOverridden
createdAt
updatedAt
deletedAt

View file

@ -12,6 +12,7 @@ export type ViewField = {
isVisible: boolean;
size: number;
aggregateOperation?: AggregateOperations | null;
isOverridden: boolean;
definition:
| ColumnDefinition<FieldMetadata>
| RecordBoardFieldDefinition<FieldMetadata>;

View file

@ -7,5 +7,6 @@ export type ViewFieldGroup = {
position: number;
isVisible: boolean;
viewId: string;
isOverridden: boolean;
viewFields: ViewField[];
};

View file

@ -7,6 +7,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
name: 'Group 1',
position: 0,
isVisible: true,
isOverridden: false,
viewId: 'view-1',
viewFields: [
{
@ -16,6 +17,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
isVisible: true,
size: 150,
aggregateOperation: null,
isOverridden: false,
},
{
id: 'vf-2',
@ -24,6 +26,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
isVisible: false,
size: 200,
aggregateOperation: null,
isOverridden: false,
},
],
};
@ -37,6 +40,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
name: 'Group 1',
position: 0,
isVisible: true,
isOverridden: false,
viewId: 'view-1',
viewFields: [
{
@ -47,6 +51,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
isVisible: true,
size: 150,
aggregateOperation: null,
isOverridden: false,
definition: undefined,
},
{
@ -57,6 +62,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
isVisible: false,
size: 200,
aggregateOperation: null,
isOverridden: false,
definition: undefined,
},
],
@ -69,6 +75,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
name: 'Empty Group',
position: 1,
isVisible: false,
isOverridden: false,
viewId: 'view-1',
viewFields: [],
};
@ -82,6 +89,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
name: 'Empty Group',
position: 1,
isVisible: false,
isOverridden: false,
viewId: 'view-1',
viewFields: [],
});
@ -93,6 +101,7 @@ describe('convertCoreViewFieldGroupToViewFieldGroup', () => {
name: 'Test',
position: 0,
isVisible: true,
isOverridden: false,
viewId: 'view-2',
viewFields: [],
};

View file

@ -62,6 +62,7 @@ describe('mapViewFieldsToColumnDefinitions', () => {
position: 1,
size: 1,
isVisible: false,
isOverridden: false,
definition: {
fieldMetadataId: '1',
label: 'label 1',
@ -81,6 +82,7 @@ describe('mapViewFieldsToColumnDefinitions', () => {
position: 2,
size: 2,
isVisible: false,
isOverridden: false,
definition: {
fieldMetadataId: '2',
label: 'label 2',
@ -100,6 +102,7 @@ describe('mapViewFieldsToColumnDefinitions', () => {
position: 3,
size: 3,
isVisible: true,
isOverridden: false,
definition: {
fieldMetadataId: '3',
label: 'label 3',
@ -193,6 +196,7 @@ describe('mapColumnDefinitionsToViewFields', () => {
fieldMetadataId: 1,
position: 1,
isVisible: true,
isOverridden: false,
definition: columnDefinitions[0],
size: undefined,
},
@ -203,6 +207,7 @@ describe('mapColumnDefinitionsToViewFields', () => {
position: 2,
size: 200,
isVisible: false,
isOverridden: false,
definition: columnDefinitions[1],
},
];

View file

@ -7,7 +7,7 @@ import {
type CoreViewFieldGroupInput = Pick<
CoreViewFieldGroup,
'id' | 'name' | 'position' | 'isVisible' | 'viewId'
'id' | 'name' | 'position' | 'isVisible' | 'viewId' | 'isOverridden'
> & {
viewFields: Pick<
CoreViewField,
@ -17,6 +17,7 @@ type CoreViewFieldGroupInput = Pick<
| 'isVisible'
| 'size'
| 'aggregateOperation'
| 'isOverridden'
>[];
};
@ -29,6 +30,7 @@ export const convertCoreViewFieldGroupToViewFieldGroup = (
name: coreViewFieldGroup.name,
position: coreViewFieldGroup.position,
isVisible: coreViewFieldGroup.isVisible,
isOverridden: coreViewFieldGroup.isOverridden ?? false,
viewId: coreViewFieldGroup.viewId,
viewFields: coreViewFieldGroup.viewFields.map(
convertCoreViewFieldToViewField,

View file

@ -12,6 +12,7 @@ export const convertCoreViewFieldToViewField = (
| 'isVisible'
| 'size'
| 'aggregateOperation'
| 'isOverridden'
>,
): ViewField => {
const viewField: ViewField = {
@ -22,6 +23,7 @@ export const convertCoreViewFieldToViewField = (
isVisible: coreViewField.isVisible,
size: coreViewField.size,
aggregateOperation: coreViewField.aggregateOperation ?? null,
isOverridden: coreViewField.isOverridden ?? false,
// TODO: remove this once we have refactored the view field definition
definition: undefined as unknown as ColumnDefinition<FieldMetadata>,
};

View file

@ -13,6 +13,7 @@ export const mapBoardFieldDefinitionsToViewFields = (
size: 0,
position: fieldDefinition.position,
isVisible: fieldDefinition.isVisible ?? true,
isOverridden: false,
definition: fieldDefinition,
}),
);

View file

@ -13,6 +13,7 @@ export const mapColumnDefinitionsToViewFields = (
position: columnDefinition.position,
size: columnDefinition.size,
isVisible: columnDefinition.isVisible ?? true,
isOverridden: false,
definition: columnDefinition,
}));
};

View file

@ -9,6 +9,7 @@ export const mapRecordFieldToViewField = (recordField: RecordField) => {
position: recordField.position,
size: recordField.size,
aggregateOperation: recordField.aggregateOperation,
isOverridden: false,
__typename: 'ViewField',
};

View file

@ -582,6 +582,7 @@ type CoreViewField {
createdAt: DateTime!
updatedAt: DateTime!
deletedAt: DateTime
isOverridden: Boolean!
}
enum AggregateOperations {
@ -690,6 +691,7 @@ type CoreViewFieldGroup {
updatedAt: DateTime!
deletedAt: DateTime
viewFields: [CoreViewField!]!
isOverridden: Boolean!
}
type CoreView {

View file

@ -416,6 +416,7 @@ export interface CoreViewField {
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
isOverridden: Scalars['Boolean']
__typename: 'CoreViewField'
}
@ -492,6 +493,7 @@ export interface CoreViewFieldGroup {
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
viewFields: CoreViewField[]
isOverridden: Scalars['Boolean']
__typename: 'CoreViewFieldGroup'
}
@ -3295,6 +3297,7 @@ export interface CoreViewFieldGenqlSelection{
createdAt?: boolean | number
updatedAt?: boolean | number
deletedAt?: boolean | number
isOverridden?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@ -3368,6 +3371,7 @@ export interface CoreViewFieldGroupGenqlSelection{
updatedAt?: boolean | number
deletedAt?: boolean | number
viewFields?: CoreViewFieldGenqlSelection
isOverridden?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}

View file

@ -1267,6 +1267,9 @@ export default {
"deletedAt": [
4
],
"isOverridden": [
6
],
"__typename": [
1
]
@ -1440,6 +1443,9 @@ export default {
"viewFields": [
50
],
"isOverridden": [
6
],
"__typename": [
1
]

View file

@ -0,0 +1,27 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddOverridesToViewFieldAndViewFieldGroup1773246310000
implements MigrationInterface
{
name = 'AddOverridesToViewFieldAndViewFieldGroup1773246310000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."viewField" ADD "overrides" jsonb`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewFieldGroup" ADD "overrides" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."viewFieldGroup" DROP COLUMN "overrides"`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewField" DROP COLUMN "overrides"`,
);
}
}

View file

@ -20,6 +20,7 @@ export const fromViewFieldGroupManifestToUniversalFlatViewFieldGroup = ({
name: viewFieldGroupManifest.name ?? '',
position: viewFieldGroupManifest.position,
isVisible: viewFieldGroupManifest.isVisible ?? true,
overrides: null,
viewFieldUniversalIdentifiers: [],
createdAt: now,
updatedAt: now,

View file

@ -25,6 +25,7 @@ export const fromViewFieldManifestToUniversalFlatViewField = ({
size: viewFieldManifest.size ?? 0,
position: viewFieldManifest.position,
aggregateOperation: viewFieldManifest.aggregateOperation ?? null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,

View file

@ -630,6 +630,36 @@ export class DataloaderService {
},
);
const viewFieldsByResolvedGroupId = new Map<
string,
ReturnType<typeof fromFlatViewFieldToViewFieldDto>[]
>();
for (const flatViewField of Object.values(
flatViewFieldMaps.byUniversalIdentifier,
)) {
if (!isDefined(flatViewField) || flatViewField.deletedAt !== null) {
continue;
}
const resolvedGroupId =
flatViewField.overrides?.viewFieldGroupId !== undefined
? flatViewField.overrides.viewFieldGroupId
: flatViewField.viewFieldGroupId;
if (!isDefined(resolvedGroupId)) {
continue;
}
if (!viewFieldsByResolvedGroupId.has(resolvedGroupId)) {
viewFieldsByResolvedGroupId.set(resolvedGroupId, []);
}
viewFieldsByResolvedGroupId
.get(resolvedGroupId)!
.push(fromFlatViewFieldToViewFieldDto(flatViewField));
}
return dataLoaderParams.map(({ viewFieldGroupId }) => {
const flatViewFieldGroup = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewFieldGroupId,
@ -640,12 +670,7 @@ export class DataloaderService {
return [];
}
return findManyFlatEntityByIdInFlatEntityMaps({
flatEntityIds: flatViewFieldGroup.viewFieldIds,
flatEntityMaps: flatViewFieldMaps,
})
.filter((flatViewField) => flatViewField.deletedAt === null)
.map(fromFlatViewFieldToViewFieldDto);
return viewFieldsByResolvedGroupId.get(viewFieldGroupId) ?? [];
});
});
}

View file

@ -265,8 +265,11 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"aggregateOperation",
"viewFieldGroupUniversalIdentifier",
"deletedAt",
"universalOverrides",
],
"propertiesToStringify": [
"universalOverrides",
],
"propertiesToStringify": [],
},
"viewFieldGroup": {
"propertiesToCompare": [
@ -274,8 +277,11 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"position",
"isVisible",
"deletedAt",
"overrides",
],
"propertiesToStringify": [
"overrides",
],
"propertiesToStringify": [],
},
"viewFilter": {
"propertiesToCompare": [

View file

@ -350,16 +350,23 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
},
},
viewFieldGroup: {
name: { toStringify: false, universalProperty: undefined, toCompare: true },
name: {
toStringify: false,
universalProperty: undefined,
toCompare: true,
isOverridable: true,
},
position: {
toStringify: false,
universalProperty: undefined,
toCompare: true,
isOverridable: true,
},
isVisible: {
toStringify: false,
universalProperty: undefined,
toCompare: true,
isOverridable: true,
},
deletedAt: {
toStringify: false,
@ -381,28 +388,42 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: 'viewUniversalIdentifier',
toStringify: false,
},
overrides: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
},
viewField: {
isVisible: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
size: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
size: { toCompare: true, toStringify: false, universalProperty: undefined },
position: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
aggregateOperation: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
isOverridable: true,
},
viewFieldGroupId: {
toStringify: false,
universalProperty: 'viewFieldGroupUniversalIdentifier',
toCompare: true,
isOverridable: true,
},
deletedAt: {
toCompare: true,
@ -429,6 +450,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: 'viewUniversalIdentifier',
},
overrides: {
toCompare: true,
toStringify: true,
universalProperty: 'universalOverrides',
},
},
viewGroup: {
isVisible: {

View file

@ -82,6 +82,8 @@ export const recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate = ({
aggregateOperation: null,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
overrides: null,
universalOverrides: null,
applicationId: existingFlatObjectMetadata.applicationId,
applicationUniversalIdentifier:
existingFlatObjectMetadata.applicationUniversalIdentifier,

View file

@ -46,6 +46,7 @@ export const fromCreateViewFieldGroupInputToFlatViewFieldGroupToCreate = ({
universalIdentifier: createViewFieldGroupInput.universalIdentifier ?? v4(),
position: createViewFieldGroupInput.position ?? 0,
isVisible: createViewFieldGroupInput.isVisible ?? true,
overrides: null,
viewFieldUniversalIdentifiers: [],
applicationUniversalIdentifier: flatApplication.universalIdentifier,
};

View file

@ -5,9 +5,11 @@ import {
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field-group/constants/flat-view-field-group-editable-properties.constant';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field-group/constants/flat-view-field-group-editable-properties.constant';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
import { type UpdateViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/update-view-field-group.input';
import {
ViewFieldGroupException,
@ -20,9 +22,13 @@ export const fromUpdateViewFieldGroupInputToFlatViewFieldGroupToUpdateOrThrow =
({
updateViewFieldGroupInput: rawUpdateViewFieldGroupInput,
flatViewFieldGroupMaps,
callerApplicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
}: {
updateViewFieldGroupInput: UpdateViewFieldGroupInput;
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
callerApplicationUniversalIdentifier: string;
workspaceCustomApplicationUniversalIdentifier: string;
}): UniversalFlatViewFieldGroup => {
const { id: viewFieldGroupToUpdateId } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
@ -43,14 +49,32 @@ export const fromUpdateViewFieldGroupInputToFlatViewFieldGroupToUpdateOrThrow =
);
}
const updatedEditableProperties = extractAndSanitizeObjectStringFields(
const editableProperties = extractAndSanitizeObjectStringFields(
rawUpdateViewFieldGroupInput.update,
FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES,
);
return mergeUpdateInExistingRecord({
existing: existingFlatViewFieldGroupToUpdate,
properties: FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES,
update: updatedEditableProperties,
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingFlatViewFieldGroupToUpdate.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties } =
sanitizeOverridableEntityInput({
metadataName: 'viewFieldGroup',
existingFlatEntity: existingFlatViewFieldGroupToUpdate,
updatedEditableProperties: editableProperties,
shouldOverride,
});
return {
...mergeUpdateInExistingRecord({
existing: existingFlatViewFieldGroupToUpdate,
properties: [...FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES],
update: updatedEditableProperties,
}),
overrides,
};
};

View file

@ -199,6 +199,7 @@ export const computeFlatViewFieldsFromFieldsWidgets = ({
size: DEFAULT_VIEW_FIELD_SIZE,
position,
aggregateOperation: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,

View file

@ -66,6 +66,7 @@ export const fromCreateViewFieldInputToFlatViewFieldToCreate = ({
size: createViewFieldInput.size ?? DEFAULT_VIEW_FIELD_SIZE,
position: createViewFieldInput.position ?? 0,
aggregateOperation: createViewFieldInput.aggregateOperation ?? null,
universalOverrides: null,
viewFieldGroupUniversalIdentifier,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
};

View file

@ -10,6 +10,9 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
import { type UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import {
ViewFieldException,
@ -22,9 +25,13 @@ export const fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow = ({
updateViewFieldInput: rawUpdateViewFieldInput,
flatViewFieldMaps,
flatViewFieldGroupMaps,
callerApplicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
}: {
updateViewFieldInput: UpdateViewFieldInput;
flatViewFieldMaps: FlatViewFieldMaps;
callerApplicationUniversalIdentifier: string;
workspaceCustomApplicationUniversalIdentifier: string;
} & Pick<
AllFlatEntityMaps,
'flatViewFieldGroupMaps'
@ -47,23 +54,43 @@ export const fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow = ({
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
const editableProperties = extractAndSanitizeObjectStringFields(
rawUpdateViewFieldInput.update,
FLAT_VIEW_FIELD_EDITABLE_PROPERTIES,
);
const flatViewFieldToUpdate = mergeUpdateInExistingRecord({
existing: existingFlatViewFieldToUpdate,
properties: FLAT_VIEW_FIELD_EDITABLE_PROPERTIES,
update: updatedEditableFieldProperties,
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingFlatViewFieldToUpdate.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier,
});
if (updatedEditableFieldProperties.viewFieldGroupId !== undefined) {
const { overrides, updatedEditableProperties } =
sanitizeOverridableEntityInput({
metadataName: 'viewField',
existingFlatEntity: existingFlatViewFieldToUpdate,
updatedEditableProperties: editableProperties,
shouldOverride,
});
const mergedRecord = mergeUpdateInExistingRecord({
existing: existingFlatViewFieldToUpdate,
properties: [...FLAT_VIEW_FIELD_EDITABLE_PROPERTIES],
update: updatedEditableProperties,
});
const flatViewFieldToUpdate = {
...mergedRecord,
overrides,
} as UniversalFlatViewField;
if (updatedEditableProperties.viewFieldGroupId !== undefined) {
const { viewFieldGroupUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: {
viewFieldGroupId: flatViewFieldToUpdate.viewFieldGroupId,
viewFieldGroupId: mergedRecord.viewFieldGroupId,
},
flatEntityMaps: { flatViewFieldGroupMaps },
});
@ -72,5 +99,16 @@ export const fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow = ({
viewFieldGroupUniversalIdentifier;
}
if (isDefined(overrides)) {
flatViewFieldToUpdate.universalOverrides =
fromViewFieldOverridesToUniversalOverrides({
overrides,
viewFieldGroupUniversalIdentifierById:
flatViewFieldGroupMaps.universalIdentifierById,
});
} else {
flatViewFieldToUpdate.universalOverrides = null;
}
return flatViewFieldToUpdate;
};

View file

@ -6,6 +6,7 @@ import {
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { getMetadataEntityRelationProperties } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-entity-relation-properties.util';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util';
import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type';
export const fromViewFieldEntityToFlatViewField = ({
@ -69,6 +70,18 @@ export const fromViewFieldEntityToFlatViewField = ({
}
}
const viewFieldGroupUniversalIdentifierById = Object.fromEntries(
viewFieldGroupIdToUniversalIdentifierMap.entries(),
);
const universalOverrides = isDefined(viewFieldEntity.overrides)
? fromViewFieldOverridesToUniversalOverrides({
overrides: viewFieldEntity.overrides,
viewFieldGroupUniversalIdentifierById,
shouldThrowOnMissingIdentifier: false,
})
: null;
return {
...viewFieldEntityWithoutRelations,
createdAt: viewFieldEntity.createdAt.toISOString(),
@ -79,5 +92,6 @@ export const fromViewFieldEntityToFlatViewField = ({
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFieldGroupUniversalIdentifier,
universalOverrides,
};
};

View file

@ -0,0 +1,54 @@
import { type FormatRecordSerializedRelationProperties } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { type ViewFieldOverrides } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
type UniversalViewFieldOverrides =
FormatRecordSerializedRelationProperties<ViewFieldOverrides>;
export const fromViewFieldOverridesToUniversalOverrides = ({
overrides,
viewFieldGroupUniversalIdentifierById,
shouldThrowOnMissingIdentifier = true,
}: {
overrides: ViewFieldOverrides;
viewFieldGroupUniversalIdentifierById: Partial<Record<string, string>>;
shouldThrowOnMissingIdentifier?: boolean;
}): UniversalViewFieldOverrides => {
const { viewFieldGroupId, ...scalarOverrides } = overrides;
if (!isDefined(viewFieldGroupId)) {
return {
...scalarOverrides,
...(viewFieldGroupId === null
? { viewFieldGroupUniversalIdentifier: null }
: {}),
};
}
const viewFieldGroupUniversalIdentifier =
viewFieldGroupUniversalIdentifierById[viewFieldGroupId];
if (!isDefined(viewFieldGroupUniversalIdentifier)) {
if (shouldThrowOnMissingIdentifier) {
throw new FlatEntityMapsException(
`ViewFieldGroup universal identifier not found for id: ${viewFieldGroupId}`,
FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND,
);
}
return {
...scalarOverrides,
viewFieldGroupUniversalIdentifier: null,
};
}
return {
...scalarOverrides,
viewFieldGroupUniversalIdentifier,
};
};

View file

@ -38,6 +38,7 @@ export const computeFlatViewFieldsToCreate = ({
size: DEFAULT_VIEW_FIELD_SIZE,
position: index,
aggregateOperation: null,
universalOverrides: null,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
}));

View file

@ -15,6 +15,7 @@ import {
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@ -70,6 +71,11 @@ export class PageLayoutTabResolver {
return resolveOverridableEntityProperty(tab, 'icon');
}
@ResolveField(() => Boolean)
isOverridden(@Parent() tab: PageLayoutTabDTO): boolean {
return isDefined(tab.overrides) && Object.keys(tab.overrides).length > 0;
}
@Query(() => [PageLayoutTabDTO])
@UseGuards(NoPermissionGuard)
async getPageLayoutTabs(

View file

@ -8,6 +8,7 @@ import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@ -116,4 +117,11 @@ export class PageLayoutWidgetResolver {
configuration(@Parent() widget: PageLayoutWidgetDTO) {
return widget.configuration;
}
@ResolveField(() => Boolean)
isOverridden(@Parent() widget: PageLayoutWidgetDTO): boolean {
return (
isDefined(widget.overrides) && Object.keys(widget.overrides).length > 0
);
}
}

View file

@ -1,8 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type ViewFieldGroupOverrides } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
@ObjectType('CoreViewFieldGroup')
@ -36,4 +37,10 @@ export class ViewFieldGroupDTO {
@Field(() => [ViewFieldDTO])
viewFields?: ViewFieldDTO[];
@Field(() => Boolean, { nullable: false })
isOverridden: boolean;
@HideField()
overrides?: ViewFieldGroupOverrides | null;
}

View file

@ -14,13 +14,19 @@ import {
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { OverridableEntity } from 'src/engine/workspace-manager/types/overridable-entity';
export type ViewFieldGroupOverrides = {
name?: string;
position?: number;
isVisible?: boolean;
};
@Entity({ name: 'viewFieldGroup', schema: 'core' })
@Index('IDX_VIEW_FIELD_GROUP_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_FIELD_GROUP_VIEW_ID', ['viewId'])
export class ViewFieldGroupEntity
extends SyncableEntity
extends OverridableEntity<ViewFieldGroupOverrides>
implements Required<ViewFieldGroupEntity>
{
@PrimaryGeneratedColumn('uuid')

View file

@ -2,6 +2,7 @@ import { UseFilters, UseGuards } from '@nestjs/common';
import {
Args,
Context,
Float,
Mutation,
Parent,
Query,
@ -9,6 +10,7 @@ import {
} from '@nestjs/graphql';
import { isArray } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@ -16,6 +18,7 @@ import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveOverridableEntityProperty } from 'src/engine/metadata-modules/utils/resolve-overridable-entity-property.util';
import { CreateViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/create-view-field-group.input';
import { DeleteViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/delete-view-field-group.input';
import { DestroyViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/destroy-view-field-group.input';
@ -39,6 +42,29 @@ export class ViewFieldGroupResolver {
private readonly fieldsWidgetUpsertService: FieldsWidgetUpsertService,
) {}
@ResolveField(() => String)
name(@Parent() viewFieldGroup: ViewFieldGroupDTO): string {
return resolveOverridableEntityProperty(viewFieldGroup, 'name');
}
@ResolveField(() => Float)
position(@Parent() viewFieldGroup: ViewFieldGroupDTO): number {
return resolveOverridableEntityProperty(viewFieldGroup, 'position');
}
@ResolveField(() => Boolean)
isVisible(@Parent() viewFieldGroup: ViewFieldGroupDTO): boolean {
return resolveOverridableEntityProperty(viewFieldGroup, 'isVisible');
}
@ResolveField(() => Boolean)
isOverridden(@Parent() viewFieldGroup: ViewFieldGroupDTO): boolean {
return (
isDefined(viewFieldGroup.overrides) &&
Object.keys(viewFieldGroup.overrides).length > 0
);
}
@Query(() => [ViewFieldGroupDTO])
@UseGuards(NoPermissionGuard)
async getCoreViewFieldGroups(

View file

@ -14,8 +14,11 @@ import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-m
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
import { type UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input';
import { UpsertFieldsWidgetGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input';
import { UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
@ -204,11 +207,30 @@ export class FieldsWidgetUpsertService {
}),
);
} else if (this.hasGroupChanged(existingGroup, inputGroup)) {
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingGroup.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties: sanitizedGroupProps } =
sanitizeOverridableEntityInput({
metadataName: 'viewFieldGroup',
existingFlatEntity: existingGroup,
updatedEditableProperties: {
name: inputGroup.name,
position: inputGroup.position,
isVisible: inputGroup.isVisible,
},
shouldOverride,
});
groupsToUpdate.push({
...existingGroup,
name: inputGroup.name,
position: inputGroup.position,
isVisible: inputGroup.isVisible,
...sanitizedGroupProps,
overrides,
updatedAt: now,
});
}
@ -251,10 +273,22 @@ export class FieldsWidgetUpsertService {
const newViewFieldGroupId = inputGroup.id;
const resolvedIsVisible = isDefined(existingField.overrides?.isVisible)
? existingField.overrides.isVisible
: existingField.isVisible;
const resolvedPosition = isDefined(existingField.overrides?.position)
? existingField.overrides.position
: existingField.position;
// null is a valid override value (meaning "ungrouped"), so use !== undefined
const resolvedViewFieldGroupId =
existingField.overrides?.viewFieldGroupId !== undefined
? existingField.overrides.viewFieldGroupId
: existingField.viewFieldGroupId;
const hasChanged =
existingField.isVisible !== inputField.isVisible ||
existingField.position !== inputField.position ||
existingField.viewFieldGroupId !== newViewFieldGroupId;
resolvedIsVisible !== inputField.isVisible ||
resolvedPosition !== inputField.position ||
resolvedViewFieldGroupId !== newViewFieldGroupId;
if (!hasChanged) {
return [];
@ -271,18 +305,72 @@ export class FieldsWidgetUpsertService {
},
});
return [
{
...existingField,
isVisible: inputField.isVisible,
position: inputField.position,
viewFieldGroupId: newViewFieldGroupId,
viewFieldGroupUniversalIdentifier,
updatedAt: now,
},
];
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingField.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties: sanitizedFieldProps } =
sanitizeOverridableEntityInput({
metadataName: 'viewField',
existingFlatEntity: existingField,
updatedEditableProperties: {
isVisible: inputField.isVisible,
position: inputField.position,
viewFieldGroupId: newViewFieldGroupId,
},
shouldOverride,
});
const updatedField: FlatViewField = {
...existingField,
...sanitizedFieldProps,
overrides,
updatedAt: now,
};
if (sanitizedFieldProps.viewFieldGroupId !== undefined) {
const resolved = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: {
viewFieldGroupId: updatedField.viewFieldGroupId,
},
flatEntityMaps: {
flatViewFieldGroupMaps: optimisticFlatViewFieldGroupMaps,
},
});
updatedField.viewFieldGroupUniversalIdentifier =
resolved.viewFieldGroupUniversalIdentifier;
}
if (isDefined(overrides)) {
updatedField.universalOverrides =
fromViewFieldOverridesToUniversalOverrides({
overrides,
viewFieldGroupUniversalIdentifierById:
optimisticFlatViewFieldGroupMaps.universalIdentifierById,
});
} else {
updatedField.universalOverrides = null;
}
return [updatedField];
});
const fieldsWithStaleGroupOverrides =
this.buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds: new Set(
viewFieldsToUpdate.map((field) => field.id),
),
now,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@ -295,7 +383,10 @@ export class FieldsWidgetUpsertService {
viewField: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: viewFieldsToUpdate,
flatEntityToUpdate: [
...viewFieldsToUpdate,
...fieldsWithStaleGroupOverrides,
],
},
},
workspaceId,
@ -338,27 +429,80 @@ export class FieldsWidgetUpsertService {
return [];
}
const resolvedIsVisible = isDefined(existingField.overrides?.isVisible)
? existingField.overrides.isVisible
: existingField.isVisible;
const resolvedPosition = isDefined(existingField.overrides?.position)
? existingField.overrides.position
: existingField.position;
const resolvedViewFieldGroupId =
existingField.overrides?.viewFieldGroupId !== undefined
? existingField.overrides.viewFieldGroupId
: existingField.viewFieldGroupId;
const hasChanged =
existingField.isVisible !== inputField.isVisible ||
existingField.position !== inputField.position ||
existingField.viewFieldGroupId !== null;
resolvedIsVisible !== inputField.isVisible ||
resolvedPosition !== inputField.position ||
resolvedViewFieldGroupId !== null;
if (!hasChanged) {
return [];
}
return [
{
...existingField,
isVisible: inputField.isVisible,
position: inputField.position,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
updatedAt: now,
},
];
const shouldOverride = isCallerOverridingEntity({
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
entityApplicationUniversalIdentifier:
existingField.applicationUniversalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
applicationUniversalIdentifier,
});
const { overrides, updatedEditableProperties: sanitizedFieldProps } =
sanitizeOverridableEntityInput({
metadataName: 'viewField',
existingFlatEntity: existingField,
updatedEditableProperties: {
isVisible: inputField.isVisible,
position: inputField.position,
viewFieldGroupId: null as string | null,
},
shouldOverride,
});
const updatedField: FlatViewField = {
...existingField,
...sanitizedFieldProps,
overrides,
updatedAt: now,
};
if (sanitizedFieldProps.viewFieldGroupId !== undefined) {
updatedField.viewFieldGroupUniversalIdentifier = null;
}
if (isDefined(overrides)) {
updatedField.universalOverrides =
fromViewFieldOverridesToUniversalOverrides({
overrides,
viewFieldGroupUniversalIdentifierById: {},
});
} else {
updatedField.universalOverrides = null;
}
return [updatedField];
});
const fieldsWithStaleGroupOverrides =
this.buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds: new Set(
viewFieldsToUpdate.map((field) => field.id),
),
now: new Date().toISOString(),
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@ -371,7 +515,10 @@ export class FieldsWidgetUpsertService {
viewField: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: viewFieldsToUpdate,
flatEntityToUpdate: [
...viewFieldsToUpdate,
...fieldsWithStaleGroupOverrides,
],
},
},
workspaceId,
@ -388,6 +535,111 @@ export class FieldsWidgetUpsertService {
}
}
private buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
groupsToDelete,
alreadyUpdatedFieldIds,
now,
}: {
existingViewFields: FlatViewField[];
groupsToDelete: FlatViewFieldGroup[];
alreadyUpdatedFieldIds: Set<string>;
now: string;
}): FlatViewField[] {
if (groupsToDelete.length === 0) {
return [];
}
const deletedGroupIds = new Set(groupsToDelete.map((group) => group.id));
return existingViewFields
.filter((field) => {
if (alreadyUpdatedFieldIds.has(field.id)) {
return false;
}
const overriddenGroupId = field.overrides?.viewFieldGroupId;
const hasStaleOverride =
isDefined(overriddenGroupId) &&
typeof overriddenGroupId === 'string' &&
deletedGroupIds.has(overriddenGroupId);
const hasStaleBase =
overriddenGroupId === undefined &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
const hasStaleBaseHiddenByNullOverride =
overriddenGroupId === null &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
return (
hasStaleOverride || hasStaleBase || hasStaleBaseHiddenByNullOverride
);
})
.map((field) => {
const overriddenGroupId = field.overrides?.viewFieldGroupId;
const hasStaleOverride =
isDefined(overriddenGroupId) &&
typeof overriddenGroupId === 'string' &&
deletedGroupIds.has(overriddenGroupId);
if (hasStaleOverride) {
const { viewFieldGroupId: _, ...remainingOverrides } =
field.overrides!;
const cleanedOverrides =
Object.keys(remainingOverrides).length > 0
? (remainingOverrides as typeof field.overrides)
: null;
const baseGroupIsAlsoStale =
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId);
return {
...field,
...(baseGroupIsAlsoStale
? {
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
}
: {}),
overrides: cleanedOverrides,
universalOverrides: isDefined(cleanedOverrides)
? fromViewFieldOverridesToUniversalOverrides({
overrides: cleanedOverrides,
viewFieldGroupUniversalIdentifierById: {},
})
: null,
updatedAt: now,
};
}
if (
overriddenGroupId === null &&
isDefined(field.viewFieldGroupId) &&
deletedGroupIds.has(field.viewFieldGroupId)
) {
return {
...field,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
updatedAt: now,
};
}
return {
...field,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
updatedAt: now,
};
});
}
private buildGroupToCreate({
inputGroup,
viewId,
@ -423,6 +675,7 @@ export class FieldsWidgetUpsertService {
isVisible: inputGroup.isVisible,
viewId,
viewUniversalIdentifier,
overrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
@ -435,10 +688,20 @@ export class FieldsWidgetUpsertService {
existing: FlatViewFieldGroup,
input: UpsertFieldsWidgetGroupInput,
): boolean {
const resolvedName = isDefined(existing.overrides?.name)
? existing.overrides.name
: existing.name;
const resolvedPosition = isDefined(existing.overrides?.position)
? existing.overrides.position
: existing.position;
const resolvedIsVisible = isDefined(existing.overrides?.isVisible)
? existing.overrides.isVisible
: existing.isVisible;
return (
existing.name !== input.name ||
existing.position !== input.position ||
existing.isVisible !== input.isVisible
resolvedName !== input.name ||
resolvedPosition !== input.position ||
resolvedIsVisible !== input.isVisible
);
}
}

View file

@ -157,6 +157,10 @@ export class ViewFieldGroupService {
fromUpdateViewFieldGroupInputToFlatViewFieldGroupToUpdateOrThrow({
flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps,
updateViewFieldGroupInput,
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const validateAndBuildResult =

View file

@ -1,3 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { type ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
@ -8,6 +10,8 @@ export const fromFlatViewFieldGroupToViewFieldGroupDto = (
return {
...rest,
isOverridden:
isDefined(rest.overrides) && Object.keys(rest.overrides).length > 0,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,

View file

@ -1,9 +1,15 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import {
Field,
HideField,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { AggregateOperations } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type ViewFieldOverrides } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
registerEnumType(AggregateOperations, { name: 'AggregateOperations' });
@ -44,4 +50,10 @@ export class ViewFieldDTO {
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
@Field(() => Boolean, { nullable: false })
isOverridden: boolean;
@HideField()
overrides?: ViewFieldOverrides | null;
}

View file

@ -10,12 +10,23 @@ import {
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { AggregateOperations } from 'twenty-shared/types';
import {
AggregateOperations,
type SerializedRelation,
} from 'twenty-shared/types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { OverridableEntity } from 'src/engine/workspace-manager/types/overridable-entity';
export type ViewFieldOverrides = {
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: AggregateOperations | null;
viewFieldGroupId?: SerializedRelation | null;
};
@Entity({ name: 'viewField', schema: 'core' })
@Index('IDX_VIEW_FIELD_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@ -30,7 +41,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
},
)
export class ViewFieldEntity
extends SyncableEntity
extends OverridableEntity<ViewFieldOverrides>
implements Required<ViewFieldEntity>
{
@PrimaryGeneratedColumn('uuid')

View file

@ -1,11 +1,24 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import {
Args,
Float,
Int,
Mutation,
Parent,
Query,
ResolveField,
} from '@nestjs/graphql';
import { AggregateOperations } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveOverridableEntityProperty } from 'src/engine/metadata-modules/utils/resolve-overridable-entity-property.util';
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
import { DeleteViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/delete-view-field.input';
import { DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/destroy-view-field.input';
@ -25,6 +38,43 @@ import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/
export class ViewFieldResolver {
constructor(private readonly viewFieldService: ViewFieldService) {}
@ResolveField(() => Boolean)
isVisible(@Parent() viewField: ViewFieldDTO): boolean {
return resolveOverridableEntityProperty(viewField, 'isVisible');
}
@ResolveField(() => Int)
size(@Parent() viewField: ViewFieldDTO): number {
return resolveOverridableEntityProperty(viewField, 'size');
}
@ResolveField(() => Float)
position(@Parent() viewField: ViewFieldDTO): number {
return resolveOverridableEntityProperty(viewField, 'position');
}
@ResolveField(() => AggregateOperations, { nullable: true })
aggregateOperation(
@Parent() viewField: ViewFieldDTO,
): AggregateOperations | null | undefined {
return resolveOverridableEntityProperty(viewField, 'aggregateOperation');
}
@ResolveField(() => UUIDScalarType, { nullable: true })
viewFieldGroupId(
@Parent() viewField: ViewFieldDTO,
): string | null | undefined {
return resolveOverridableEntityProperty(viewField, 'viewFieldGroupId');
}
@ResolveField(() => Boolean)
isOverridden(@Parent() viewField: ViewFieldDTO): boolean {
return (
isDefined(viewField.overrides) &&
Object.keys(viewField.overrides).length > 0
);
}
@Query(() => [ViewFieldDTO])
@UseGuards(NoPermissionGuard)
async getCoreViewFields(

View file

@ -167,6 +167,10 @@ export class ViewFieldService {
flatViewFieldMaps: existingFlatViewFieldMaps,
flatViewFieldGroupMaps,
updateViewFieldInput,
callerApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
const validateAndBuildResult =

View file

@ -1,3 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
@ -8,6 +10,8 @@ export const fromFlatViewFieldToViewFieldDto = (
return {
...rest,
isOverridden:
isDefined(rest.overrides) && Object.keys(rest.overrides).length > 0,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,

View file

@ -69,6 +69,7 @@ export const createStandardViewFieldGroupFlatMetadata = <
name,
position,
isVisible,
overrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,

View file

@ -120,6 +120,8 @@ export const createStandardViewFieldFlatMetadata = <
isVisible,
size,
aggregateOperation,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,

View file

@ -13,7 +13,9 @@ export const ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION_BY_METADATA_NAME = {
},
objectMetadata: {},
view: {},
viewField: {},
viewField: {
overrides: 'overrides',
},
viewGroup: {},
viewFieldGroup: {},
viewFilter: {},

View file

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
@ -10,6 +11,7 @@ import {
FlatCreateViewFieldAction,
UniversalCreateViewFieldAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field/types/workspace-migration-view-field-action.type';
import { fromUniversalOverridesToViewFieldOverrides } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view-field/services/utils/from-universal-overrides-to-view-field-overrides.util';
import {
WorkspaceMigrationActionRunnerArgs,
WorkspaceMigrationActionRunnerContext,
@ -37,6 +39,13 @@ export class CreateViewFieldActionHandlerService extends WorkspaceMigrationRunne
universalForeignKeyValues: action.flatEntity,
});
const overrides = isDefined(action.flatEntity.universalOverrides)
? fromUniversalOverridesToViewFieldOverrides({
universalOverrides: action.flatEntity.universalOverrides,
flatViewFieldGroupMaps: allFlatEntityMaps.flatViewFieldGroupMaps,
})
: null;
const emptyUniversalForeignKeyAggregators =
getUniversalFlatEntityEmptyForeignKeyAggregators({
metadataName: 'viewField',
@ -49,6 +58,7 @@ export class CreateViewFieldActionHandlerService extends WorkspaceMigrationRunne
fieldMetadataId,
viewId,
viewFieldGroupId,
overrides,
id: action.id ?? v4(),
applicationId: flatApplication.id,
workspaceId,

View file

@ -9,6 +9,7 @@ import {
FlatUpdateViewFieldAction,
UniversalUpdateViewFieldAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field/types/workspace-migration-view-field-action.type';
import { fromUniversalOverridesToViewFieldOverrides } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/view-field/services/utils/from-universal-overrides-to-view-field-overrides.util';
import {
WorkspaceMigrationActionRunnerArgs,
WorkspaceMigrationActionRunnerContext,
@ -33,11 +34,26 @@ export class UpdateViewFieldActionHandlerService extends WorkspaceMigrationRunne
universalIdentifier: action.universalIdentifier,
});
const update = resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'viewField',
universalUpdate: action.update,
allFlatEntityMaps,
});
const { universalOverrides, ...updateWithResolvedForeignKeys } =
resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'viewField',
universalUpdate: action.update,
allFlatEntityMaps,
});
const update =
universalOverrides === undefined
? updateWithResolvedForeignKeys
: universalOverrides === null
? { ...updateWithResolvedForeignKeys, overrides: null }
: {
...updateWithResolvedForeignKeys,
overrides: fromUniversalOverridesToViewFieldOverrides({
universalOverrides,
flatViewFieldGroupMaps:
allFlatEntityMaps.flatViewFieldGroupMaps,
}),
};
return {
type: 'update',

View file

@ -0,0 +1,41 @@
import { type FormatRecordSerializedRelationProperties } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { type ViewFieldOverrides } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
type UniversalViewFieldOverrides =
FormatRecordSerializedRelationProperties<ViewFieldOverrides>;
export const fromUniversalOverridesToViewFieldOverrides = ({
universalOverrides,
flatViewFieldGroupMaps,
}: {
universalOverrides: UniversalViewFieldOverrides;
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
}): ViewFieldOverrides => {
const { viewFieldGroupUniversalIdentifier, ...scalarOverrides } =
universalOverrides;
if (!isDefined(viewFieldGroupUniversalIdentifier)) {
return {
...scalarOverrides,
...(viewFieldGroupUniversalIdentifier === null
? { viewFieldGroupId: null }
: {}),
};
}
const flatViewFieldGroup =
findFlatEntityByUniversalIdentifier<FlatViewFieldGroup>({
flatEntityMaps: flatViewFieldGroupMaps,
universalIdentifier: viewFieldGroupUniversalIdentifier,
});
return {
...scalarOverrides,
viewFieldGroupId: flatViewFieldGroup?.id ?? null,
};
};