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