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
@@ -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,