Add weekly layout to record calendar (#22819)

## Summary

- Add a week layout to record calendar views and persist the selected
layout.
- Render `DATE` calendars as an all-day week and `DATE_TIME` calendars
as an hourly week.
- Add an optional end date field across calendar configuration,
metadata, persistence, and complete-view upserts.
- Use configured end values for ranged and multi-day events, with a
one-hour fallback when a `DATE_TIME` end is absent or invalid.
- Keep calendar cards consistent with the existing compact view,
including checkbox selection and whole-card record opening.
- Gate the weekly layout and end-date behavior behind the public Labs
`IS_CALENDAR_WEEK_VIEW_ENABLED` workspace feature flag.

## Week interactions

- Show overlapping timed events side by side and cap the visible records
at two per day.
- Display start and end times on timed cards, enforce a readable
30-minute minimum height, and keep today’s text contrast stronger.
- Drag timed events between days and times with 30-minute snapping while
preserving their duration, including zero-duration events.
- Show a create button when hovering a 30-minute slot; keyboard users
can focus a day, move the slot with the arrow keys, and reach the same
contextual action.
- Initialize new records with the selected slot time and a compatible
writable end value one hour later.
- Show the workspace time zone and current-time indicator in timed
weeks; date-only weeks keep the all-day section without an hourly grid.

## Configuration and data loading

- Only allow end fields that match the start field type, and prevent
selecting the same field for both boundaries.
- Load records whose ranges overlap the visible period so month and week
layouts display the same relevant records.
- Resolve and persist calendar end fields when updating existing views
through `upsert_complete_view`.
- Fall back to Month and ignore the configured end field while the flag
is disabled, without overwriting either persisted setting, so
re-enabling restores the previous configuration.
- Expose the flag in Labs and keep it default-off for workspaces without
a stored value; enable it in the development seeder.

<img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17"
src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b"
/>
This commit is contained in:
Weiko
2026-07-15 16:30:18 +02:00
committed by GitHub
parent 0dbae2eda3
commit 25bd2897a3
158 changed files with 5446 additions and 214 deletions
@@ -114,6 +114,7 @@ describe('fromViewManifestToUniversalFlatView', () => {
).toBeNull();
expect(result.calendarLayout).toBeNull();
expect(result.calendarFieldMetadataUniversalIdentifier).toBeNull();
expect(result.calendarEndFieldMetadataUniversalIdentifier).toBeNull();
expect(result.anyFieldFilterValue).toBeNull();
});
@@ -126,6 +127,7 @@ describe('fromViewManifestToUniversalFlatView', () => {
type: ViewType.CALENDAR,
calendarLayout: ViewCalendarLayout.WEEK,
calendarFieldMetadataUniversalIdentifier: 'field-uuid-date',
calendarEndFieldMetadataUniversalIdentifier: 'field-uuid-end-date',
},
applicationUniversalIdentifier,
now,
@@ -135,6 +137,9 @@ describe('fromViewManifestToUniversalFlatView', () => {
expect(result.calendarFieldMetadataUniversalIdentifier).toBe(
'field-uuid-date',
);
expect(result.calendarEndFieldMetadataUniversalIdentifier).toBe(
'field-uuid-end-date',
);
});
it('should preserve anyFieldFilterValue from the manifest', () => {
@@ -112,6 +112,7 @@ export const fromFieldManifestToUniversalFlatFieldMetadata = ({
fieldPermissionUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
searchFieldMetadataUniversalIdentifiers: [],
@@ -36,6 +36,8 @@ export const fromViewManifestToUniversalFlatView = ({
calendarLayout: viewManifest.calendarLayout ?? null,
calendarFieldMetadataUniversalIdentifier:
viewManifest.calendarFieldMetadataUniversalIdentifier ?? null,
calendarEndFieldMetadataUniversalIdentifier:
viewManifest.calendarEndFieldMetadataUniversalIdentifier ?? null,
mainGroupByFieldMetadataUniversalIdentifier:
viewManifest.mainGroupByFieldMetadataUniversalIdentifier ?? null,
shouldHideEmptyGroups: viewManifest.shouldHideEmptyGroups ?? false,
@@ -12,6 +12,14 @@ export type PublicFeatureFlag = {
};
export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
{
key: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
metadata: {
label: 'Calendar Week View',
description:
'Display calendar records in a weekly layout with optional end dates',
},
},
{
key: FeatureFlagKey.IS_JUNCTION_RELATIONS_ENABLED,
metadata: {
@@ -249,6 +249,9 @@ export class FieldMetadataEntity<
@OneToMany(() => ViewEntity, (view) => view.calendarFieldMetadata)
calendarViews: Relation<ViewEntity[]>;
@OneToMany(() => ViewEntity, (view) => view.calendarEndFieldMetadata)
calendarEndViews: Relation<ViewEntity[]>;
@OneToMany(() => ViewEntity, (view) => view.mainGroupByFieldMetadata)
mainGroupByFieldMetadataViews: Relation<ViewEntity[]>;
@@ -343,6 +343,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"anyFieldFilterValue",
"calendarLayout",
"calendarFieldMetadataUniversalIdentifier",
"calendarEndFieldMetadataUniversalIdentifier",
"visibility",
"mainGroupByFieldMetadataUniversalIdentifier",
"shouldHideEmptyGroups",
@@ -69,6 +69,7 @@ exports[`registry-derived override property maps derives the overridable propert
"anyFieldFilterValue",
"calendarLayout",
"calendarFieldMetadataId",
"calendarEndFieldMetadataId",
"visibility",
"mainGroupByFieldMetadataId",
"shouldHideEmptyGroups",
@@ -381,6 +381,12 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: 'calendarFieldMetadataUniversalIdentifier',
isOverridable: true,
},
calendarEndFieldMetadataId: {
toCompare: true,
toStringify: false,
universalProperty: 'calendarEndFieldMetadataUniversalIdentifier',
isOverridable: true,
},
visibility: {
toCompare: true,
toStringify: false,
@@ -98,6 +98,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
calendarFieldMetadata: {
foreignKey: 'calendarFieldMetadataId',
},
calendarEndFieldMetadata: {
foreignKey: 'calendarEndFieldMetadataId',
},
kanbanAggregateOperationFieldMetadata: {
foreignKey: 'kanbanAggregateOperationFieldMetadataId',
},
@@ -174,6 +174,13 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
isNullable: true,
universalForeignKey: 'calendarFieldMetadataUniversalIdentifier',
},
calendarEndFieldMetadata: {
metadataName: 'fieldMetadata',
foreignKey: 'calendarEndFieldMetadataId',
inverseOneToManyProperty: 'calendarEndViews',
isNullable: true,
universalForeignKey: 'calendarEndFieldMetadataUniversalIdentifier',
},
kanbanAggregateOperationFieldMetadata: {
metadataName: 'fieldMetadata',
foreignKey: 'kanbanAggregateOperationFieldMetadataId',
@@ -71,6 +71,12 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
universalFlatEntityForeignKeyAggregator:
'calendarViewUniversalIdentifiers',
},
calendarEndViews: {
metadataName: 'view',
flatEntityForeignKeyAggregator: 'calendarEndViewIds',
universalFlatEntityForeignKeyAggregator:
'calendarEndViewUniversalIdentifiers',
},
mainGroupByFieldMetadataViews: {
metadataName: 'view',
flatEntityForeignKeyAggregator: 'mainGroupByFieldMetadataViewIds',
@@ -17,7 +17,7 @@ type FieldMetadataOneToManySyncableRelations =
type Assertions = [
// FieldMetadataEntity OneToMany relations (all targets):
// - indexFieldMetadatas, fieldPermissions, viewFields, viewFilters,
// - kanbanAggregateOperationViews, calendarViews, mainGroupByFieldMetadataViews,
// - kanbanAggregateOperationViews, calendarViews, calendarEndViews, mainGroupByFieldMetadataViews,
// - viewSorts, searchFieldMetadatas
Expect<
Equal<
@@ -28,6 +28,7 @@ type Assertions = [
| 'viewFilters'
| 'kanbanAggregateOperationViews'
| 'calendarViews'
| 'calendarEndViews'
| 'mainGroupByFieldMetadataViews'
| 'viewSorts'
| 'searchFieldMetadatas'
@@ -43,6 +44,7 @@ type Assertions = [
| 'viewFilters'
| 'kanbanAggregateOperationViews'
| 'calendarViews'
| 'calendarEndViews'
| 'mainGroupByFieldMetadataViews'
| 'viewSorts'
| 'searchFieldMetadatas'
@@ -10,7 +10,7 @@ type FieldMetadataRelatedProperties =
type Assertions = [
// FieldMetadataEntity has both ManyToOne and OneToMany relations
// ManyToOne: object, workspace, application, relationTargetFieldMetadata, relationTargetObjectMetadata
// OneToMany: indexFieldMetadatas, fieldPermissions, viewFields, viewFilters, kanbanAggregateOperationViews, calendarViews, mainGroupByFieldMetadataViews, viewSorts, searchFieldMetadatas
// OneToMany: indexFieldMetadatas, fieldPermissions, viewFields, viewFilters, kanbanAggregateOperationViews, calendarViews, calendarEndViews, mainGroupByFieldMetadataViews, viewSorts, searchFieldMetadatas
Expect<
Equal<
FieldMetadataRelatedProperties,
@@ -25,6 +25,7 @@ type Assertions = [
| 'viewFilters'
| 'kanbanAggregateOperationViews'
| 'calendarViews'
| 'calendarEndViews'
| 'mainGroupByFieldMetadataViews'
| 'viewSorts'
| 'searchFieldMetadatas'
@@ -57,6 +57,8 @@ describe('addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow',
applicationId,
calendarFieldMetadataId: mockFieldMetadata.id,
calendarFieldMetadataUniversalIdentifier: fieldUniversalIdentifier,
calendarEndFieldMetadataId: mockFieldMetadata.id,
calendarEndFieldMetadataUniversalIdentifier: fieldUniversalIdentifier,
};
const flatEntityAndRelatedMapsToMutate: MetadataFlatEntityAndRelatedFlatEntityMaps<'view'> =
@@ -103,6 +105,8 @@ describe('addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow',
).toMatchObject<Partial<FlatFieldMetadata>>({
calendarViewIds: [mockView.id],
calendarViewUniversalIdentifiers: [viewUniversalIdentifier],
calendarEndViewIds: [mockView.id],
calendarEndViewUniversalIdentifiers: [viewUniversalIdentifier],
});
});
});
@@ -43,6 +43,8 @@ describe('deleteFlatEntityFromFlatEntityAndRelatedEntityMapsThroughMutationOrThr
workspaceId,
calendarViewIds: [viewId],
calendarViewUniversalIdentifiers: [viewUniversalIdentifier],
calendarEndViewIds: [viewId],
calendarEndViewUniversalIdentifiers: [viewUniversalIdentifier],
mainGroupByFieldMetadataViewIds: [],
applicationId,
});
@@ -58,6 +60,8 @@ describe('deleteFlatEntityFromFlatEntityAndRelatedEntityMapsThroughMutationOrThr
workspaceId,
calendarFieldMetadataId: mockFieldMetadata.id,
calendarFieldMetadataUniversalIdentifier: fieldUniversalIdentifier,
calendarEndFieldMetadataId: mockFieldMetadata.id,
calendarEndFieldMetadataUniversalIdentifier: fieldUniversalIdentifier,
createdAt: new Date('2024-01-01').toISOString(),
updatedAt: new Date('2024-01-01').toISOString(),
icon: 'icon',
@@ -114,6 +118,8 @@ describe('deleteFlatEntityFromFlatEntityAndRelatedEntityMapsThroughMutationOrThr
).toMatchObject<Partial<FlatFieldMetadata>>({
calendarViewIds: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewIds: [],
calendarEndViewUniversalIdentifiers: [],
});
});
});
@@ -17,6 +17,7 @@ export const getFlatFieldMetadataMock = <T extends FieldMetadataType>(
return {
calendarViewIds: [],
calendarEndViewIds: [],
viewFilterIds: [],
kanbanAggregateOperationViewIds: [],
viewFieldIds: [],
@@ -54,6 +55,7 @@ export const getFlatFieldMetadataMock = <T extends FieldMetadataType>(
fieldPermissionUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
viewSortIds: [],
viewSortUniversalIdentifiers: [],
@@ -117,6 +117,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"flatFieldMetadatas": [
{
"applicationUniversalIdentifier": Any<String>,
"calendarEndViewUniversalIdentifiers": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": Any<String>,
"defaultValue": null,
@@ -153,6 +154,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
},
{
"applicationUniversalIdentifier": Any<String>,
"calendarEndViewUniversalIdentifiers": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": Any<String>,
"defaultValue": null,
@@ -191,6 +193,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
},
{
"applicationUniversalIdentifier": Any<String>,
"calendarEndViewUniversalIdentifiers": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": Any<String>,
"defaultValue": null,
@@ -227,6 +230,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
},
{
"applicationUniversalIdentifier": Any<String>,
"calendarEndViewUniversalIdentifiers": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": Any<String>,
"defaultValue": null,
@@ -108,6 +108,7 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceCachePro
'universalIdentifier',
'kanbanAggregateOperationFieldMetadataId',
'calendarFieldMetadataId',
'calendarEndFieldMetadataId',
'mainGroupByFieldMetadataId',
],
withDeleted: true,
@@ -121,6 +122,7 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceCachePro
viewFieldsByFieldId,
viewFiltersByFieldId,
calendarViewsByFieldId,
calendarEndViewsByFieldId,
kanbanViewsByFieldId,
mainGroupByFieldMetadataViewsByFieldId,
viewSortsByFieldId,
@@ -139,6 +141,10 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceCachePro
entities: views,
foreignKey: 'calendarFieldMetadataId',
},
{
entities: views,
foreignKey: 'calendarEndFieldMetadataId',
},
{
entities: views,
foreignKey: 'kanbanAggregateOperationFieldMetadataId',
@@ -180,6 +186,8 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceCachePro
kanbanViewsByFieldId.get(fieldMetadataEntity.id) || [],
calendarViews:
calendarViewsByFieldId.get(fieldMetadataEntity.id) || [],
calendarEndViews:
calendarEndViewsByFieldId.get(fieldMetadataEntity.id) || [],
mainGroupByFieldMetadataViews:
mainGroupByFieldMetadataViewsByFieldId.get(
fieldMetadataEntity.id,
@@ -30,6 +30,7 @@ type OneToManyRelationIdArrays = {
viewFilterIds: string[];
kanbanAggregateOperationViewIds: string[];
calendarViewIds: string[];
calendarEndViewIds: string[];
mainGroupByFieldMetadataViewIds: string[];
};
@@ -0,0 +1,86 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { handleFieldMetadataDeactivationSideEffects } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-field-metadata-deactivation-side-effects.util';
const getFlatView = (id: string): FlatView =>
({
id,
universalIdentifier: `${id}-universal-identifier`,
applicationId: 'application-id',
calendarEndFieldMetadataId: 'calendar-end-field-id',
calendarEndFieldMetadataUniversalIdentifier:
'calendar-end-field-universal-identifier',
}) as FlatView;
describe('handleFieldMetadataDeactivationSideEffects', () => {
const getEmptyRelatedMaps = () => ({
flatViewFieldMaps: createEmptyFlatEntityMaps(),
flatViewFilterMaps: createEmptyFlatEntityMaps(),
flatViewGroupMaps: createEmptyFlatEntityMaps(),
});
it('clears an optional calendar end field without deleting the view', () => {
const view = getFlatView('calendar-view-id');
const fromFlatFieldMetadata = getFlatFieldMetadataMock({
id: 'calendar-end-field-id',
objectMetadataId: 'object-id',
universalIdentifier: 'calendar-end-field-universal-identifier',
type: FieldMetadataType.DATE_TIME,
calendarEndViewIds: [view.id],
});
const result = handleFieldMetadataDeactivationSideEffects({
fromFlatFieldMetadata,
toFlatFieldMetadata: {
...fromFlatFieldMetadata,
isActive: false,
},
flatViewMaps: addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: view,
flatEntityMaps: createEmptyFlatEntityMaps(),
}),
...getEmptyRelatedMaps(),
});
expect(result.flatViewsToDelete).toEqual([]);
expect(result.flatViewsToUpdate).toEqual([
expect.objectContaining({
id: view.id,
calendarEndFieldMetadataId: null,
calendarEndFieldMetadataUniversalIdentifier: null,
}),
]);
});
it('deletes the view when the deactivated field is both its start and end field', () => {
const view = getFlatView('calendar-view-id');
const fromFlatFieldMetadata = getFlatFieldMetadataMock({
id: 'calendar-field-id',
objectMetadataId: 'object-id',
universalIdentifier: 'calendar-field-universal-identifier',
type: FieldMetadataType.DATE,
calendarViewIds: [view.id],
calendarEndViewIds: [view.id],
});
const result = handleFieldMetadataDeactivationSideEffects({
fromFlatFieldMetadata,
toFlatFieldMetadata: {
...fromFlatFieldMetadata,
isActive: false,
},
flatViewMaps: addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: view,
flatEntityMaps: createEmptyFlatEntityMaps(),
}),
...getEmptyRelatedMaps(),
});
expect(result.flatViewsToDelete).toEqual([view]);
expect(result.flatViewsToUpdate).toEqual([]);
});
});
@@ -49,6 +49,9 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = (
kanbanAggregateOperationViewIds:
fieldMetadataEntity.kanbanAggregateOperationViews.map(({ id }) => id),
calendarViewIds: fieldMetadataEntity.calendarViews.map(({ id }) => id),
calendarEndViewIds: fieldMetadataEntity.calendarEndViews.map(
({ id }) => id,
),
mainGroupByFieldMetadataViewIds:
fieldMetadataEntity.mainGroupByFieldMetadataViews?.map(({ id }) => id) ??
[],
@@ -69,6 +72,10 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = (
calendarViewUniversalIdentifiers: fieldMetadataEntity.calendarViews.map(
({ universalIdentifier }) => universalIdentifier,
),
calendarEndViewUniversalIdentifiers:
fieldMetadataEntity.calendarEndViews.map(
({ universalIdentifier }) => universalIdentifier,
),
mainGroupByFieldMetadataViewUniversalIdentifiers:
fieldMetadataEntity.mainGroupByFieldMetadataViews?.map(
({ universalIdentifier }) => universalIdentifier,
@@ -74,6 +74,7 @@ export const getDefaultFlatFieldMetadata = ({
fieldPermissionUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
universalSettings: settings ?? null,
viewSortUniversalIdentifiers: [],
@@ -85,19 +85,34 @@ export const handleFieldMetadataDeactivationSideEffects = ({
flatEntityMaps: flatViewMaps,
});
const viewIdsToUpdate =
fromFlatFieldMetadata.kanbanAggregateOperationViewIds.filter(
(viewId) => !viewIdsToDelete.includes(viewId),
);
const kanbanAggregateOperationViewIds = new Set(
fromFlatFieldMetadata.kanbanAggregateOperationViewIds,
);
const calendarEndViewIds = new Set(fromFlatFieldMetadata.calendarEndViewIds);
const viewIdsToUpdate = [
...new Set([...kanbanAggregateOperationViewIds, ...calendarEndViewIds]),
].filter((viewId) => !viewIdsToDelete.includes(viewId));
const flatViewsToUpdate = findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityIds: viewIdsToUpdate,
flatEntityMaps: flatViewMaps,
}).map((flatView) => ({
...flatView,
kanbanAggregateOperation: null,
kanbanAggregateOperationFieldMetadataId: null,
kanbanAggregateOperationFieldMetadataUniversalIdentifier: null,
}));
}).map((flatView) => {
const shouldClearKanbanAggregateOperation =
kanbanAggregateOperationViewIds.has(flatView.id);
const shouldClearCalendarEndField = calendarEndViewIds.has(flatView.id);
return {
...flatView,
...(shouldClearKanbanAggregateOperation && {
kanbanAggregateOperation: null,
kanbanAggregateOperationFieldMetadataId: null,
kanbanAggregateOperationFieldMetadataUniversalIdentifier: null,
}),
...(shouldClearCalendarEndField && {
calendarEndFieldMetadataId: null,
calendarEndFieldMetadataUniversalIdentifier: null,
}),
};
});
return {
flatViewsToUpdate,
@@ -12,6 +12,7 @@ export const FLAT_VIEW_EDITABLE_PROPERTIES = [
'anyFieldFilterValue',
'calendarLayout',
'calendarFieldMetadataId',
'calendarEndFieldMetadataId',
'visibility',
'mainGroupByFieldMetadataId',
'shouldHideEmptyGroups',
@@ -45,6 +45,7 @@ export const fromCreateViewInputToFlatViewToCreate = ({
const {
objectMetadataUniversalIdentifier,
calendarFieldMetadataUniversalIdentifier,
calendarEndFieldMetadataUniversalIdentifier,
kanbanAggregateOperationFieldMetadataUniversalIdentifier,
mainGroupByFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
@@ -52,6 +53,7 @@ export const fromCreateViewInputToFlatViewToCreate = ({
foreignKeyValues: {
objectMetadataId,
calendarFieldMetadataId: createViewInput.calendarFieldMetadataId,
calendarEndFieldMetadataId: createViewInput.calendarEndFieldMetadataId,
kanbanAggregateOperationFieldMetadataId:
createViewInput.kanbanAggregateOperationFieldMetadataId,
mainGroupByFieldMetadataId: createViewInput.mainGroupByFieldMetadataId,
@@ -72,6 +74,7 @@ export const fromCreateViewInputToFlatViewToCreate = ({
isCustom: true,
anyFieldFilterValue: createViewInput.anyFieldFilterValue ?? null,
calendarFieldMetadataUniversalIdentifier,
calendarEndFieldMetadataUniversalIdentifier,
calendarLayout: createViewInput.calendarLayout ?? null,
icon: createViewInput.icon,
isCompact: createViewInput.isCompact ?? false,
@@ -129,6 +129,20 @@ export const fromUpdateViewInputToFlatViewToUpdateOrThrow = ({
calendarFieldMetadataUniversalIdentifier;
}
if (updatedEditableProperties.calendarEndFieldMetadataId !== undefined) {
const { calendarEndFieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'view',
foreignKeyValues: {
calendarEndFieldMetadataId: mergedRecord.calendarEndFieldMetadataId,
},
flatEntityMaps: { flatFieldMetadataMaps },
});
flatViewToUpdate.calendarEndFieldMetadataUniversalIdentifier =
calendarEndFieldMetadataUniversalIdentifier;
}
if (updatedEditableProperties.mainGroupByFieldMetadataId !== undefined) {
const { mainGroupByFieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
@@ -13,6 +13,7 @@ type UniversalViewOverrides =
const VIEW_OVERRIDES_FIELD_METADATA_FOREIGN_KEYS = [
'kanbanAggregateOperationFieldMetadataId',
'calendarFieldMetadataId',
'calendarEndFieldMetadataId',
'mainGroupByFieldMetadataId',
] as const;
@@ -39,6 +40,7 @@ export const fromViewOverridesToUniversalOverrides = ({
const {
kanbanAggregateOperationFieldMetadataId: _kanban,
calendarFieldMetadataId: _calendar,
calendarEndFieldMetadataId: _calendarEnd,
mainGroupByFieldMetadataId: _mainGroupBy,
...scalarOverrides
} = overrides;
@@ -37,6 +37,7 @@ const PARTIAL_ID_FIELD = {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
@@ -67,6 +68,7 @@ const PARTIAL_CREATED_AT_FIELD = {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
@@ -97,6 +99,7 @@ const PARTIAL_UPDATED_AT_FIELD = {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
@@ -127,6 +130,7 @@ const PARTIAL_DELETED_AT_FIELD = {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
@@ -157,6 +161,7 @@ const PARTIAL_CREATED_BY_FIELD = {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
@@ -187,6 +192,7 @@ const PARTIAL_UPDATED_BY_FIELD = {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
@@ -217,6 +223,7 @@ const PARTIAL_POSITION_FIELD = {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
@@ -249,6 +256,7 @@ const PARTIAL_SEARCH_VECTOR_FIELD = {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortUniversalIdentifiers: [],
@@ -717,6 +717,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
isCustom: true,
anyFieldFilterValue: null,
calendarFieldMetadataUniversalIdentifier: null,
calendarEndFieldMetadataUniversalIdentifier: null,
calendarLayout: null,
isCompact: false,
shouldHideEmptyGroups: false,
@@ -51,6 +51,7 @@ export const buildNameFlatFieldMetadataForCustomObject = ({
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
universalSettings: null,
@@ -31,6 +31,7 @@ export const computeFlatRecordPageFieldsViewToCreate = ({
isCustom: true,
anyFieldFilterValue: null,
calendarFieldMetadataUniversalIdentifier: null,
calendarEndFieldMetadataUniversalIdentifier: null,
calendarLayout: null,
isCompact: false,
shouldHideEmptyGroups: false,
@@ -111,6 +111,11 @@ export class CreateViewInput {
@Field(() => UUIDScalarType, { nullable: true })
calendarFieldMetadataId?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
calendarEndFieldMetadataId?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
@@ -88,7 +88,12 @@ export class UpdateViewInput {
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
calendarFieldMetadataId?: string;
calendarFieldMetadataId?: string | null;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
calendarEndFieldMetadataId?: string | null;
@IsOptional()
@IsEnum(ViewVisibility)
@@ -82,6 +82,9 @@ export class ViewDTO {
@Field(() => UUIDScalarType, { nullable: true })
calendarFieldMetadataId?: string | null;
@Field(() => UUIDScalarType, { nullable: true })
calendarEndFieldMetadataId?: string | null;
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
@@ -26,6 +26,7 @@ import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorato
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ADD_IS_SYSTEM_SIDE_EFFECT_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-15/is-system-side-effect-upgrade-command-name.constant';
import { ADD_VIEW_KANBAN_COLUMN_WIDTH_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-15/add-view-kanban-column-width-upgrade-command-name.constant';
import { ADD_CALENDAR_END_FIELD_METADATA_ID_TO_VIEW_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-22/add-calendar-end-field-metadata-id-to-view-upgrade-command-name.constant';
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 { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
@@ -48,6 +49,7 @@ export type ViewOverrides = {
anyFieldFilterValue?: string | null;
calendarLayout?: ViewCalendarLayout | null;
calendarFieldMetadataId?: SerializedRelation | null;
calendarEndFieldMetadataId?: SerializedRelation | null;
visibility?: ViewVisibility;
mainGroupByFieldMetadataId?: SerializedRelation | null;
shouldHideEmptyGroups?: boolean;
@@ -62,6 +64,7 @@ export type ViewOverrides = {
])
@Index('IDX_VIEW_VISIBILITY', ['visibility'])
@Index('IDX_VIEW_CALENDAR_FIELD_METADATA', ['calendarFieldMetadataId'])
@Index('IDX_VIEW_CALENDAR_END_FIELD_METADATA', ['calendarEndFieldMetadataId'])
@Index('IDX_VIEW_KANBAN_FIELD_METADATA', [
'kanbanAggregateOperationFieldMetadataId',
])
@@ -170,6 +173,24 @@ export class ViewEntity
@JoinColumn({ name: 'calendarFieldMetadataId' })
calendarFieldMetadata: Relation<FieldMetadataEntity> | null;
@WasIntroducedInUpgrade({
upgradeCommandName:
ADD_CALENDAR_END_FIELD_METADATA_ID_TO_VIEW_UPGRADE_COMMAND_NAME,
})
@Column({ nullable: true, type: 'uuid' })
calendarEndFieldMetadataId: string | null;
@ManyToOne(
() => FieldMetadataEntity,
(fieldMetadata) => fieldMetadata.calendarEndViews,
{
onDelete: 'SET NULL',
nullable: true,
},
)
@JoinColumn({ name: 'calendarEndFieldMetadataId' })
calendarEndFieldMetadata: Relation<FieldMetadataEntity> | null;
@Column({ nullable: true, type: 'uuid' })
mainGroupByFieldMetadataId: string | null;
@@ -30,6 +30,7 @@ describe('ViewToolsFactory', () => {
const mockObjectMetadataId = 'object-metadata-id';
const mockObjectNameSingular = 'company';
const mockCalendarFieldMetadataId = 'calendar-field-metadata-id';
const mockCalendarEndFieldMetadataId = 'calendar-end-field-metadata-id';
const mockNameFieldMetadataId = 'name-field-metadata-id';
const mockStageFieldMetadataId = 'stage-field-metadata-id';
@@ -43,6 +44,13 @@ describe('ViewToolsFactory', () => {
objectMetadataId: mockObjectMetadataId,
universalIdentifier: 'field-universal-id',
},
'end-field-universal-id': {
id: mockCalendarEndFieldMetadataId,
name: 'endsAt',
type: FieldMetadataType.DATE_TIME,
objectMetadataId: mockObjectMetadataId,
universalIdentifier: 'end-field-universal-id',
},
'name-field-universal-id': {
id: mockNameFieldMetadataId,
name: 'name',
@@ -783,6 +791,40 @@ describe('ViewToolsFactory', () => {
expect(viewService.createOne).not.toHaveBeenCalled();
});
it('should resolve and update the calendar end field on an existing view', async () => {
const existingView = {
...mockView,
type: ViewType.CALENDAR,
visibility: ViewVisibility.WORKSPACE,
};
viewService.findById.mockResolvedValue(existingView as any);
completeViewUpsertService.upsertCompleteView.mockResolvedValue({
...existingView,
calendarEndFieldMetadataId: mockCalendarEndFieldMetadataId,
} as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await callExecute(tools['upsert_complete_view'], {
id: mockViewId,
calendarEndFieldName: 'endsAt',
});
expect(
completeViewUpsertService.upsertCompleteView,
).toHaveBeenCalledWith(
expect.objectContaining({
existingViewId: mockViewId,
objectMetadataId: mockObjectMetadataId,
calendarEndFieldMetadataId: mockCalendarEndFieldMetadataId,
}),
);
});
it('should pass an empty sorts array through to upsertCompleteView on update', async () => {
const existingView = {
...mockView,
@@ -72,6 +72,7 @@ export class CompleteViewUpsertService {
kanbanAggregateOperationFieldMetadataId,
calendarLayout,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
fields,
filters,
sorts,
@@ -89,6 +90,7 @@ export class CompleteViewUpsertService {
kanbanAggregateOperationFieldMetadataId?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldMetadataId?: string;
calendarEndFieldMetadataId?: string;
fields?: CompleteViewFieldSpec[];
filters?: CompleteViewFilterSpec[];
sorts?: CompleteViewSortSpec[];
@@ -138,6 +140,7 @@ export class CompleteViewUpsertService {
existingViewId,
name,
icon,
calendarEndFieldMetadataId,
userWorkspaceId,
applicationUniversalIdentifier,
flatViewMaps,
@@ -155,6 +158,7 @@ export class CompleteViewUpsertService {
kanbanAggregateOperationFieldMetadataId,
calendarLayout,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
userWorkspaceId,
flatApplication: workspaceCustomFlatApplication,
flatFieldMetadataMaps,
@@ -251,6 +255,7 @@ export class CompleteViewUpsertService {
kanbanAggregateOperationFieldMetadataId,
calendarLayout,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
userWorkspaceId,
flatApplication,
flatFieldMetadataMaps,
@@ -266,6 +271,7 @@ export class CompleteViewUpsertService {
kanbanAggregateOperationFieldMetadataId?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldMetadataId?: string;
calendarEndFieldMetadataId?: string;
userWorkspaceId?: string;
flatApplication: FlatApplication;
flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps'];
@@ -291,6 +297,7 @@ export class CompleteViewUpsertService {
kanbanAggregateOperationFieldMetadataId,
calendarLayout,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
},
createdByUserWorkspaceId: userWorkspaceId,
flatApplication,
@@ -322,6 +329,7 @@ export class CompleteViewUpsertService {
existingViewId,
name,
icon,
calendarEndFieldMetadataId,
userWorkspaceId,
applicationUniversalIdentifier,
flatViewMaps,
@@ -331,19 +339,29 @@ export class CompleteViewUpsertService {
existingViewId: string;
name?: string;
icon?: string;
calendarEndFieldMetadataId?: string;
userWorkspaceId?: string;
applicationUniversalIdentifier: string;
flatViewMaps: AllFlatEntityMaps['flatViewMaps'];
flatViewGroupMaps: AllFlatEntityMaps['flatViewGroupMaps'];
flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps'];
}): ViewUpsertRootOperations {
if (!isDefined(name) && !isDefined(icon)) {
if (
!isDefined(name) &&
!isDefined(icon) &&
!isDefined(calendarEndFieldMetadataId)
) {
return { viewId: existingViewId };
}
const { flatViewToUpdate, flatViewGroupsToDelete, flatViewGroupsToCreate } =
fromUpdateViewInputToFlatViewToUpdateOrThrow({
updateViewInput: { id: existingViewId, name, icon },
updateViewInput: {
id: existingViewId,
name,
icon,
calendarEndFieldMetadataId,
},
flatViewMaps,
flatViewGroupMaps,
flatFieldMetadataMaps,
@@ -107,6 +107,12 @@ const CreateViewInputSchema = z.object({
.describe(
'Date field name to use for the calendar (required for CALENDAR views, must be a DATE or DATE_TIME field, e.g., "createdAt", "dueAt")',
),
calendarEndFieldName: z
.string()
.optional()
.describe(
'Optional end date field name for the calendar. It must have the same DATE or DATE_TIME type as calendarFieldName.',
),
fieldNames: z
.array(z.string())
.optional()
@@ -244,6 +250,12 @@ const UpsertCompleteViewInputSchema = z.object({
.describe(
'Date field name for the calendar (required for CALENDAR, must be DATE or DATE_TIME).',
),
calendarEndFieldName: z
.string()
.optional()
.describe(
'Optional end date field name for the calendar. It must match the type of calendarFieldName.',
),
fields: z
.array(UpsertCompleteViewFieldSchema)
.optional()
@@ -272,6 +284,7 @@ type UpsertCompleteViewIdentifiers = {
mainGroupByFieldMetadataId?: string;
kanbanAggregateOperationFieldMetadataId?: string;
calendarFieldMetadataId?: string;
calendarEndFieldMetadataId?: string;
};
@Injectable()
@@ -448,6 +461,7 @@ export class ViewToolsFactory {
kanbanAggregateOperationFieldName?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldName?: string;
calendarEndFieldName?: string;
};
workspaceId: string;
userWorkspaceId?: string;
@@ -469,9 +483,20 @@ export class ViewToolsFactory {
throw new Error('You can only update your own unlisted views');
}
const calendarEndFieldMetadataId = isDefined(
parameters.calendarEndFieldName,
)
? await this.resolveCalendarFieldMetadataId(
workspaceId,
existingView.objectMetadataId,
parameters.calendarEndFieldName,
)
: undefined;
return {
existingViewId: existingView.id,
objectMetadataId: existingView.objectMetadataId,
calendarEndFieldMetadataId,
};
}
@@ -537,11 +562,22 @@ export class ViewToolsFactory {
)
: undefined;
const calendarEndFieldMetadataId = isDefined(
parameters.calendarEndFieldName,
)
? await this.resolveCalendarFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.calendarEndFieldName,
)
: undefined;
return {
objectMetadataId,
mainGroupByFieldMetadataId,
kanbanAggregateOperationFieldMetadataId,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
};
}
@@ -636,6 +672,7 @@ VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT fie
kanbanAggregateOperationFieldName?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldName?: string;
calendarEndFieldName?: string;
fields?: Array<
FieldReference & { isVisible?: boolean; size?: number }
>;
@@ -655,6 +692,7 @@ VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT fie
mainGroupByFieldMetadataId,
kanbanAggregateOperationFieldMetadataId,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
} = await this.resolveUpsertCompleteViewIdentifiersOrThrow({
parameters,
workspaceId,
@@ -718,6 +756,7 @@ VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT fie
kanbanAggregateOperationFieldMetadataId,
calendarLayout: parameters.calendarLayout,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
fields,
filters,
sorts,
@@ -757,6 +796,7 @@ VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT fie
kanbanAggregateOperationFieldName?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldName?: string;
calendarEndFieldName?: string;
fieldNames?: string[];
}) => {
try {
@@ -791,6 +831,7 @@ VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT fie
let mainGroupByFieldMetadataId: string | undefined;
let kanbanAggregateOperationFieldMetadataId: string | undefined;
let calendarFieldMetadataId: string | undefined;
let calendarEndFieldMetadataId: string | undefined;
if (parameters.mainGroupByFieldName) {
mainGroupByFieldMetadataId =
@@ -819,6 +860,15 @@ VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT fie
);
}
if (parameters.calendarEndFieldName) {
calendarEndFieldMetadataId =
await this.resolveCalendarFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.calendarEndFieldName,
);
}
const view = await this.viewService.createOne({
createViewInput: {
name: parameters.name,
@@ -832,6 +882,7 @@ VIEW TYPES: TABLE (default), KANBAN (requires mainGroupByFieldName, a SELECT fie
kanbanAggregateOperationFieldMetadataId,
calendarLayout: parameters.calendarLayout,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
},
workspaceId,
createdByUserWorkspaceId: userWorkspaceId,
@@ -169,6 +169,7 @@ describe('WorkspaceEntityManager', () => {
fieldPermissionIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
calendarEndViewIds: [],
mainGroupByFieldMetadataViewIds: [],
relationTargetFieldMetadataId: null,
relationTargetObjectMetadataId: null,
@@ -182,6 +183,7 @@ describe('WorkspaceEntityManager', () => {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortIds: [],
@@ -236,6 +238,7 @@ describe('WorkspaceEntityManager', () => {
featureFlagsMap: {
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_CALENDAR_WEEK_VIEW_ENABLED: false,
IS_EMAIL_GROUP_ENABLED: false,
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false,
@@ -92,6 +92,7 @@ describe('WorkspaceRepository', () => {
relationTargetFieldMetadataId: null,
relationTargetObjectMetadataId: null,
calendarViewIds: [],
calendarEndViewIds: [],
viewFilterIds: [],
fieldPermissionIds: [],
kanbanAggregateOperationViewIds: [],
@@ -105,6 +106,7 @@ describe('WorkspaceRepository', () => {
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
viewSortIds: [],
@@ -18,6 +18,8 @@ const INTRODUCE_STEP = '2.7.0_Introduce_1800000000000';
@WasIntroducedInUpgrade({ upgradeCommandName: INTRODUCE_STEP })
class UnavailableEntity {}
class EntityWithHiddenColumn {}
describe('wrapRepositoryWithUpgradeAwareProxy', () => {
it('short-circuits find() to an empty array when the entity is unavailable', async () => {
const metadata = {
@@ -71,4 +73,79 @@ describe('wrapRepositoryWithUpgradeAwareProxy', () => {
await expect(wrapped.find()).resolves.toEqual([]);
expect(find).not.toHaveBeenCalled();
});
it('strips hidden columns from an array select while preserving unknown keys', async () => {
const find = jest.fn().mockResolvedValue([]);
const repository = {
find,
metadata: {
relations: [],
targetName: EntityWithHiddenColumn.name,
},
} as unknown as Repository<EntityWithHiddenColumn>;
const state = {
getHiddenColumnPropertyNames: jest
.fn()
.mockReturnValue(new Set(['introducedColumn'])),
isEntityAvailable: jest.fn().mockReturnValue(true),
} as unknown as UpgradeAwareRepositoryState;
const wrapped = wrapRepositoryWithUpgradeAwareProxy({
repository,
entityClass: EntityWithHiddenColumn,
state,
});
await (
wrapped.find as unknown as (options: {
select: string[];
}) => Promise<unknown>
)({
select: ['id', 'introducedColumn', 'unknownTypo'],
});
expect(find).toHaveBeenCalledWith({
select: ['id', 'unknownTypo'],
});
});
it('strips hidden columns from an object select while preserving unknown keys', async () => {
const find = jest.fn().mockResolvedValue([]);
const repository = {
find,
metadata: {
relations: [],
targetName: EntityWithHiddenColumn.name,
},
} as unknown as Repository<EntityWithHiddenColumn>;
const state = {
getHiddenColumnPropertyNames: jest
.fn()
.mockReturnValue(new Set(['introducedColumn'])),
isEntityAvailable: jest.fn().mockReturnValue(true),
} as unknown as UpgradeAwareRepositoryState;
const wrapped = wrapRepositoryWithUpgradeAwareProxy({
repository,
entityClass: EntityWithHiddenColumn,
state,
});
await (
wrapped.find as unknown as (options: {
select: Record<string, boolean>;
}) => Promise<unknown>
)({
select: {
id: true,
introducedColumn: true,
unknownTypo: true,
},
});
expect(find).toHaveBeenCalledWith({
select: {
id: true,
unknownTypo: true,
},
});
});
});
@@ -109,6 +109,61 @@ const METHODS_THAT_ACCEPT_FIND_OPTIONS = new Set<string>([
'existsBy',
]);
const stripUnavailableSelect = (
entityClass: Function,
state: UpgradeAwareRepositoryState,
options: unknown,
): unknown => {
if (!isDefined(options) || typeof options !== 'object') {
return options;
}
const withSelect = options as { select?: unknown };
if (!isDefined(withSelect.select)) {
return options;
}
const hiddenColumnPropertyNames =
state.getHiddenColumnPropertyNames(entityClass);
if (hiddenColumnPropertyNames.size === 0) {
return options;
}
if (Array.isArray(withSelect.select)) {
const filtered = withSelect.select.filter(
(propertyName) =>
typeof propertyName !== 'string' ||
!hiddenColumnPropertyNames.has(propertyName),
);
if (filtered.length === withSelect.select.length) {
return options;
}
return { ...withSelect, select: filtered };
}
if (typeof withSelect.select === 'object') {
const filtered = Object.fromEntries(
Object.entries(withSelect.select).filter(
([propertyName]) => !hiddenColumnPropertyNames.has(propertyName),
),
);
if (
Object.keys(filtered).length === Object.keys(withSelect.select).length
) {
return options;
}
return { ...withSelect, select: filtered };
}
return options;
};
const stripUnavailableRelations = (
metadata: EntityMetadata,
state: UpgradeAwareRepositoryState,
@@ -262,7 +317,11 @@ const handleRepositoryMethodCall = <Entity extends object>({
const rewrittenArgs =
METHODS_THAT_ACCEPT_FIND_OPTIONS.has(methodName) && args.length > 0
? [
stripUnavailableRelations(target.metadata, state, args[0]),
stripUnavailableSelect(
entityClass,
state,
stripUnavailableRelations(target.metadata, state, args[0]),
),
...args.slice(1),
]
: args;
@@ -25,6 +25,11 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
workspaceId: workspaceId,
@@ -100,6 +100,7 @@ export const createStandardFieldFlatMetadata = <
fieldPermissionIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
calendarEndViewIds: [],
mainGroupByFieldMetadataViewIds: [],
createdAt: now,
updatedAt: now,
@@ -114,6 +115,7 @@ export const createStandardFieldFlatMetadata = <
fieldPermissionUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
viewSortIds: [],
viewSortUniversalIdentifiers: [],
@@ -119,6 +119,7 @@ export const createStandardRelationFieldFlatMetadata = <
fieldPermissionIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
calendarEndViewIds: [],
mainGroupByFieldMetadataViewIds: [],
createdAt: now,
updatedAt: now,
@@ -135,6 +136,7 @@ export const createStandardRelationFieldFlatMetadata = <
fieldPermissionUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
viewSortIds: [],
viewSortUniversalIdentifiers: [],
@@ -29,6 +29,7 @@ export type CreateStandardViewOptions<O extends AllStandardObjectName> = {
kanbanAggregateOperationFieldName?: AllStandardObjectFieldName<O>;
mainGroupByFieldName?: AllStandardObjectFieldName<O>;
calendarFieldName?: AllStandardObjectFieldName<O>;
calendarEndFieldName?: AllStandardObjectFieldName<O>;
};
export type CreateStandardViewArgs<
@@ -57,6 +58,7 @@ export const createStandardViewFlatMetadata = <
kanbanAggregateOperationFieldName,
mainGroupByFieldName,
calendarFieldName,
calendarEndFieldName,
},
standardObjectMetadataRelatedEntityIds,
twentyStandardApplicationId,
@@ -95,6 +97,12 @@ export const createStandardViewFlatMetadata = <
].id
: null;
const calendarEndFieldMetadataId = calendarEndFieldName
? standardObjectMetadataRelatedEntityIds[objectName].fields[
calendarEndFieldName
].id
: null;
const kanbanAggregateOperationFieldMetadataUniversalIdentifier =
kanbanAggregateOperationFieldName
? // @ts-expect-error ignore
@@ -113,8 +121,15 @@ export const createStandardViewFlatMetadata = <
STANDARD_OBJECTS[objectName].fields[calendarFieldName].universalIdentifier
: null;
const calendarEndFieldMetadataUniversalIdentifier = calendarEndFieldName
? // @ts-expect-error ignore
STANDARD_OBJECTS[objectName].fields[calendarEndFieldName]
.universalIdentifier
: null;
return {
calendarFieldMetadataUniversalIdentifier,
calendarEndFieldMetadataUniversalIdentifier,
kanbanAggregateOperationFieldMetadataUniversalIdentifier,
mainGroupByFieldMetadataUniversalIdentifier,
objectMetadataUniversalIdentifier,
@@ -140,6 +155,7 @@ export const createStandardViewFlatMetadata = <
kanbanColumnWidth: null,
calendarLayout: null,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
anyFieldFilterValue: null,
visibility: ViewVisibility.WORKSPACE,
createdByUserWorkspaceId: null,
@@ -13,6 +13,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should matc
"viewFilterUniversalIdentifiers",
"kanbanAggregateOperationViewUniversalIdentifiers",
"calendarViewUniversalIdentifiers",
"calendarEndViewUniversalIdentifiers",
"mainGroupByFieldMetadataViewUniversalIdentifiers",
"searchFieldMetadataUniversalIdentifiers",
],
@@ -39,6 +39,7 @@ type OneToManyUniversalIdentifierArrays = {
viewFilterUniversalIdentifiers: string[];
kanbanAggregateOperationViewUniversalIdentifiers: string[];
calendarViewUniversalIdentifiers: string[];
calendarEndViewUniversalIdentifiers: string[];
mainGroupByFieldMetadataViewUniversalIdentifiers: string[];
};
@@ -61,6 +61,7 @@ type Assertions = [
| 'kanbanAggregateOperationFieldMetadataUniversalIdentifier'
| 'calendarLayout'
| 'calendarFieldMetadataUniversalIdentifier'
| 'calendarEndFieldMetadataUniversalIdentifier'
| 'mainGroupByFieldMetadataUniversalIdentifier'
| 'shouldHideEmptyGroups'
| 'kanbanColumnWidth'
@@ -7,6 +7,8 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a crea
"universal-identifier-1": {
"applicationId": "application-id-1",
"applicationUniversalIdentifier": "application-universal-identifier-1",
"calendarEndViewIds": [],
"calendarEndViewUniversalIdentifiers": [],
"calendarViewIds": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": "2024-01-01T00:00:00.000Z",
@@ -74,6 +76,8 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a dele
"universal-identifier-1": {
"applicationId": "application-id-1",
"applicationUniversalIdentifier": "application-universal-identifier-1",
"calendarEndViewIds": [],
"calendarEndViewUniversalIdentifiers": [],
"calendarViewIds": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": "2024-01-01T00:00:00.000Z",
@@ -155,6 +159,8 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create
"universal-identifier-3": {
"applicationId": "application-id-1",
"applicationUniversalIdentifier": "application-universal-identifier-1",
"calendarEndViewIds": [],
"calendarEndViewUniversalIdentifiers": [],
"calendarViewIds": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": "2024-01-01T00:00:00.000Z",
@@ -208,6 +214,8 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create
"universal-identifier-2": {
"applicationId": "application-id-1",
"applicationUniversalIdentifier": "application-universal-identifier-1",
"calendarEndViewIds": [],
"calendarEndViewUniversalIdentifiers": [],
"calendarViewIds": [],
"calendarViewUniversalIdentifiers": [],
"createdAt": "2024-01-01T00:00:00.000Z",
@@ -67,6 +67,7 @@ describe('addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThrough
viewFieldUniversalIdentifiers: [],
viewFilterUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
createdAt: '2024-01-01T00:00:00.000Z',
@@ -100,6 +101,8 @@ describe('addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThrough
applicationUniversalIdentifier,
calendarFieldMetadataUniversalIdentifier:
fieldMetadataUniversalIdentifier,
calendarEndFieldMetadataUniversalIdentifier:
fieldMetadataUniversalIdentifier,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
name: 'Test View',
@@ -176,6 +179,7 @@ describe('addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThrough
expect(updatedFieldMetadata).toMatchObject({
calendarViewUniversalIdentifiers: [viewUniversalIdentifier],
calendarEndViewUniversalIdentifiers: [viewUniversalIdentifier],
});
});
@@ -63,6 +63,7 @@ describe('deleteUniversalFlatEntityFromUniversalFlatEntityAndRelatedEntityMapsTh
viewFieldUniversalIdentifiers: [],
viewFilterUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [viewUniversalIdentifier],
calendarEndViewUniversalIdentifiers: [viewUniversalIdentifier],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
createdAt: '2024-01-01T00:00:00.000Z',
@@ -96,6 +97,8 @@ describe('deleteUniversalFlatEntityFromUniversalFlatEntityAndRelatedEntityMapsTh
applicationUniversalIdentifier,
calendarFieldMetadataUniversalIdentifier:
fieldMetadataUniversalIdentifier,
calendarEndFieldMetadataUniversalIdentifier:
fieldMetadataUniversalIdentifier,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
name: 'Test View',
@@ -185,6 +188,7 @@ describe('deleteUniversalFlatEntityFromUniversalFlatEntityAndRelatedEntityMapsTh
expect(updatedFieldMetadata).toMatchObject({
calendarViewUniversalIdentifiers: [],
calendarEndViewUniversalIdentifiers: [],
});
});
});
@@ -6,9 +6,13 @@ import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { isMorphOrRelationUniversalFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { ViewExceptionCode } from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import {
type FailedFlatEntityValidation,
type FlatEntityValidationError,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
import { type UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type';
@@ -16,6 +20,142 @@ import { type UniversalFlatEntityValidationArgs } from 'src/engine/workspace-man
export class FlatViewValidatorService {
constructor() {}
private validateCalendarFields({
flatView,
flatFieldMetadataMaps,
}: {
flatView: UniversalFlatView;
flatFieldMetadataMaps: AllUniversalFlatEntityMaps['flatFieldMetadataMaps'];
}): FlatEntityValidationError[] {
if (flatView.type !== ViewType.CALENDAR) {
return [];
}
const errors: FlatEntityValidationError[] = [];
if (!isDefined(flatView.calendarLayout)) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar view must have a calendar layout`,
userFriendlyMessage: msg`Calendar view must have a calendar layout`,
});
}
if (!isDefined(flatView.calendarFieldMetadataUniversalIdentifier)) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar view must have a calendar field`,
userFriendlyMessage: msg`Calendar view must have a calendar field`,
});
return errors;
}
const calendarFieldMetadata = findFlatEntityByUniversalIdentifier({
universalIdentifier: flatView.calendarFieldMetadataUniversalIdentifier,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(calendarFieldMetadata)) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar field metadata not found`,
userFriendlyMessage: msg`Calendar field not found`,
});
return errors;
}
if (
calendarFieldMetadata.objectMetadataUniversalIdentifier !==
flatView.objectMetadataUniversalIdentifier
) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar field must belong to the view object`,
userFriendlyMessage: msg`Calendar field must belong to the view object`,
});
}
const calendarFieldIsDateKind =
calendarFieldMetadata.type === FieldMetadataType.DATE ||
calendarFieldMetadata.type === FieldMetadataType.DATE_TIME;
if (!calendarFieldIsDateKind) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar field must be a date or date time field`,
userFriendlyMessage: msg`Calendar field must be a date or date time field`,
});
}
if (!isDefined(flatView.calendarEndFieldMetadataUniversalIdentifier)) {
return errors;
}
if (
flatView.calendarEndFieldMetadataUniversalIdentifier ===
flatView.calendarFieldMetadataUniversalIdentifier
) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar start and end fields must be different`,
userFriendlyMessage: msg`Calendar start and end fields must be different`,
});
return errors;
}
const calendarEndFieldMetadata = findFlatEntityByUniversalIdentifier({
universalIdentifier: flatView.calendarEndFieldMetadataUniversalIdentifier,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(calendarEndFieldMetadata)) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar end field metadata not found`,
userFriendlyMessage: msg`Calendar end field not found`,
});
return errors;
}
if (
calendarEndFieldMetadata.objectMetadataUniversalIdentifier !==
flatView.objectMetadataUniversalIdentifier
) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar end field must belong to the view object`,
userFriendlyMessage: msg`Calendar end field must belong to the view object`,
});
}
const calendarEndFieldIsDateKind =
calendarEndFieldMetadata.type === FieldMetadataType.DATE ||
calendarEndFieldMetadata.type === FieldMetadataType.DATE_TIME;
if (!calendarEndFieldIsDateKind) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar end field must be a date or date time field`,
userFriendlyMessage: msg`Calendar end field must be a date or date time field`,
});
} else if (
calendarFieldIsDateKind &&
calendarEndFieldMetadata.type !== calendarFieldMetadata.type
) {
errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Calendar start and end fields must have the same type`,
userFriendlyMessage: msg`Calendar start and end fields must have the same type`,
});
}
return errors;
}
private isAllowedKanbanMainGroupByField({
mainGroupByFieldMetadata,
}: {
@@ -174,6 +314,13 @@ export class FlatViewValidatorService {
}
}
validationResult.errors.push(
...this.validateCalendarFields({
flatView: updatedFlatView,
flatFieldMetadataMaps,
}),
);
return validationResult;
}
@@ -345,6 +492,13 @@ export class FlatViewValidatorService {
}
}
validationResult.errors.push(
...this.validateCalendarFields({
flatView: flatViewToValidate,
flatFieldMetadataMaps,
}),
);
return validationResult;
}
}
@@ -132,6 +132,7 @@ export const fromUniversalFlatFieldMetadataToFlatFieldMetadata = ({
viewFilterIds: [],
fieldPermissionIds: [],
calendarViewIds: [],
calendarEndViewIds: [],
mainGroupByFieldMetadataViewIds: [],
kanbanAggregateOperationViewIds: [],
viewSortIds: [],
@@ -34,6 +34,7 @@ export class CreateViewActionHandlerService extends WorkspaceMigrationRunnerActi
}: WorkspaceMigrationActionRunnerArgs<UniversalCreateViewAction>): Promise<FlatCreateViewAction> {
const {
calendarFieldMetadataId,
calendarEndFieldMetadataId,
kanbanAggregateOperationFieldMetadataId,
mainGroupByFieldMetadataId,
objectMetadataId,
@@ -60,6 +61,7 @@ export class CreateViewActionHandlerService extends WorkspaceMigrationRunnerActi
flatEntity: {
...action.flatEntity,
calendarFieldMetadataId,
calendarEndFieldMetadataId,
kanbanAggregateOperationFieldMetadataId,
mainGroupByFieldMetadataId,
objectMetadataId,
@@ -11,6 +11,7 @@ type UniversalViewOverrides =
const VIEW_OVERRIDES_UNIVERSAL_FIELD_METADATA_PROPERTIES = [
'kanbanAggregateOperationFieldMetadataUniversalIdentifier',
'calendarFieldMetadataUniversalIdentifier',
'calendarEndFieldMetadataUniversalIdentifier',
'mainGroupByFieldMetadataUniversalIdentifier',
] as const;
@@ -35,6 +36,7 @@ export const fromUniversalOverridesToViewOverrides = ({
const {
kanbanAggregateOperationFieldMetadataUniversalIdentifier: _kanban,
calendarFieldMetadataUniversalIdentifier: _calendar,
calendarEndFieldMetadataUniversalIdentifier: _calendarEnd,
mainGroupByFieldMetadataUniversalIdentifier: _mainGroupBy,
...scalarOverrides
} = universalOverrides;