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:
+1
@@ -13,6 +13,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should matc
|
||||
"viewFilterUniversalIdentifiers",
|
||||
"kanbanAggregateOperationViewUniversalIdentifiers",
|
||||
"calendarViewUniversalIdentifiers",
|
||||
"calendarEndViewUniversalIdentifiers",
|
||||
"mainGroupByFieldMetadataViewUniversalIdentifiers",
|
||||
"searchFieldMetadataUniversalIdentifiers",
|
||||
],
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ type OneToManyUniversalIdentifierArrays = {
|
||||
viewFilterUniversalIdentifiers: string[];
|
||||
kanbanAggregateOperationViewUniversalIdentifiers: string[];
|
||||
calendarViewUniversalIdentifiers: string[];
|
||||
calendarEndViewUniversalIdentifiers: string[];
|
||||
mainGroupByFieldMetadataViewUniversalIdentifiers: string[];
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -61,6 +61,7 @@ type Assertions = [
|
||||
| 'kanbanAggregateOperationFieldMetadataUniversalIdentifier'
|
||||
| 'calendarLayout'
|
||||
| 'calendarFieldMetadataUniversalIdentifier'
|
||||
| 'calendarEndFieldMetadataUniversalIdentifier'
|
||||
| 'mainGroupByFieldMetadataUniversalIdentifier'
|
||||
| 'shouldHideEmptyGroups'
|
||||
| 'kanbanColumnWidth'
|
||||
|
||||
+8
@@ -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",
|
||||
|
||||
+4
@@ -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],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+4
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+155
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -132,6 +132,7 @@ export const fromUniversalFlatFieldMetadataToFlatFieldMetadata = ({
|
||||
viewFilterIds: [],
|
||||
fieldPermissionIds: [],
|
||||
calendarViewIds: [],
|
||||
calendarEndViewIds: [],
|
||||
mainGroupByFieldMetadataViewIds: [],
|
||||
kanbanAggregateOperationViewIds: [],
|
||||
viewSortIds: [],
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user