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:
+3
@@ -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,
|
||||
|
||||
+2
@@ -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: [],
|
||||
|
||||
+77
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+60
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user