Move is active to fe (#19649)

## Context
Moving isActive filtering to the frontend for page layout tabs and
widgets, hiding inactive entities from the UI while keeping them in
state for future reactivation

Next we will implement deactivated standard tab re-activation during tab
creation (cc @Devessier)
<img width="234" height="303" alt="📋 Menu (Slots)"
src="https://github.com/user-attachments/assets/17a25ac6-55e2-4778-b7f0-e7554ed69704"
/>
This commit is contained in:
Weiko
2026-04-14 12:18:09 +02:00
committed by GitHub
parent b817bdca02
commit 47bdcb11d8
83 changed files with 481 additions and 467 deletions
@@ -29,7 +29,9 @@ export const reconstructFlatPageLayoutWithTabsAndWidgets = ({
}): FlatPageLayoutWithTabsAndWidgets => {
const tabs = Object.values(flatPageLayoutTabMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((tab) => tab.pageLayoutId === layout.id && tab.isActive)
.filter(
(tab) => tab.pageLayoutId === layout.id && !isDefined(tab.deletedAt),
)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
const tabsWithWidgets: FlatPageLayoutTabWithWidgets[] = tabs.map((tab) => {
@@ -39,7 +41,8 @@ export const reconstructFlatPageLayoutWithTabsAndWidgets = ({
.filter(isDefined)
.filter(
(widget) =>
getResolvedPageLayoutTabId(widget) === tab.id && widget.isActive,
getResolvedPageLayoutTabId(widget) === tab.id &&
!isDefined(widget.deletedAt),
);
return {
@@ -52,6 +52,9 @@ export class PageLayoutTabDTO {
@Field()
updatedAt: Date;
@Field(() => Boolean, { nullable: false })
isActive: boolean;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
@@ -74,6 +74,9 @@ export class PageLayoutWidgetDTO {
@Field()
updatedAt: Date;
@Field(() => Boolean, { nullable: false })
isActive: boolean;
@Field(() => Date, { nullable: true })
deletedAt?: Date;
@@ -898,11 +898,6 @@ export class PageLayoutUpdateService {
for (const widget of widgetsToUpdate) {
if (!widget.isActive) {
directlyRemovedWidgetIds.add(widget.id);
const viewId = this.getViewIdFromFieldsWidget(widget);
if (isDefined(viewId)) {
viewIdsToDelete.add(viewId);
}
}
}
@@ -32,6 +32,9 @@ export class ViewFieldGroupDTO {
@Field()
updatedAt: Date;
@Field(() => Boolean, { nullable: false })
isActive: boolean;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
@@ -349,12 +349,7 @@ export class ViewFieldGroupService {
return Object.values(flatViewFieldGroupMaps.byUniversalIdentifier)
.filter(isDefined)
.filter(
(group) =>
group.viewId === viewId &&
group.isActive &&
!isDefined(group.deletedAt),
)
.filter((group) => group.viewId === viewId && !isDefined(group.deletedAt))
.map(fromFlatViewFieldGroupToViewFieldGroupDto)
.sort((a, b) => a.position - b.position);
}
@@ -48,6 +48,9 @@ export class ViewFieldDTO {
@Field()
updatedAt: Date;
@Field(() => Boolean, { nullable: false })
isActive: boolean;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
@@ -352,7 +352,7 @@ export class ViewFieldService {
return Object.values(flatViewFieldMaps.byUniversalIdentifier)
.filter(isDefined)
.filter((field) => field.isActive && !isDefined(field.deletedAt))
.filter((field) => !isDefined(field.deletedAt))
.map(fromFlatViewFieldToViewFieldDto)
.sort((a, b) => a.position - b.position);
}
@@ -371,12 +371,7 @@ export class ViewFieldService {
return Object.values(flatViewFieldMaps.byUniversalIdentifier)
.filter(isDefined)
.filter(
(field) =>
field.viewId === viewId &&
field.isActive &&
!isDefined(field.deletedAt),
)
.filter((field) => field.viewId === viewId && !isDefined(field.deletedAt))
.map(fromFlatViewFieldToViewFieldDto)
.sort((a, b) => a.position - b.position);
}
@@ -11,7 +11,7 @@ import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { enrichCommandMenuItemEventWithResolvedNavigation } from 'src/engine/subscriptions/metadata-event/utils/enrich-command-menu-item-event-with-resolved-navigation.util';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
import { sanitizeOverridableEntityEventBatch } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util';
import { resolveOverridableEntityEventBatchOverrides } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util';
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
@Injectable()
@@ -64,7 +64,7 @@ export class MetadataEventPublisher {
metadataEventBatch as MetadataEventBatch<'objectMetadata'>,
);
default:
return sanitizeOverridableEntityEventBatch(metadataEventBatch);
return resolveOverridableEntityEventBatchOverrides(metadataEventBatch);
}
}
@@ -1,5 +1,5 @@
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { sanitizeOverridableEntityEventBatch } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util';
import { resolveOverridableEntityEventBatchOverrides } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util';
const makeViewFieldRecord = (
overrides?: Partial<Record<string, unknown>>,
@@ -35,7 +35,7 @@ const makeBatch = (
events,
}) as MetadataEventBatch;
describe('sanitizeOverridableEntityEventBatch', () => {
describe('resolveOverridableEntityEventBatchOverrides', () => {
describe('non-overridable entity (pass-through)', () => {
it('should return the batch unchanged for entities without overrides config', () => {
const batch = makeBatch('view', [
@@ -52,14 +52,14 @@ describe('sanitizeOverridableEntityEventBatch', () => {
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const result = resolveOverridableEntityEventBatchOverrides(batch);
expect(result).toBe(batch);
});
});
describe('override resolution', () => {
it('should resolve overrides into base properties and strip overrides/isActive', () => {
it('should resolve overrides into base properties and strip overrides but keep isActive', () => {
const after = makeViewFieldRecord({
isVisible: true,
overrides: { isVisible: false },
@@ -74,7 +74,7 @@ describe('sanitizeOverridableEntityEventBatch', () => {
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const result = resolveOverridableEntityEventBatchOverrides(batch);
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
@@ -82,10 +82,10 @@ describe('sanitizeOverridableEntityEventBatch', () => {
expect(createdRecord.isVisible).toBe(false);
expect(createdRecord).not.toHaveProperty('overrides');
expect(createdRecord).not.toHaveProperty('isActive');
expect(createdRecord).toHaveProperty('isActive', true);
});
it('should strip overrides and isActive even when overrides is null', () => {
it('should strip overrides but keep isActive even when overrides is null', () => {
const after = makeViewFieldRecord({ overrides: null });
const batch = makeBatch('viewField', [
@@ -97,7 +97,7 @@ describe('sanitizeOverridableEntityEventBatch', () => {
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const result = resolveOverridableEntityEventBatchOverrides(batch);
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
@@ -105,7 +105,7 @@ describe('sanitizeOverridableEntityEventBatch', () => {
expect(createdRecord.isVisible).toBe(true);
expect(createdRecord).not.toHaveProperty('overrides');
expect(createdRecord).not.toHaveProperty('isActive');
expect(createdRecord).toHaveProperty('isActive', true);
});
it('should resolve multiple override properties', () => {
@@ -125,7 +125,7 @@ describe('sanitizeOverridableEntityEventBatch', () => {
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const result = resolveOverridableEntityEventBatchOverrides(batch);
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
@@ -160,7 +160,7 @@ describe('sanitizeOverridableEntityEventBatch', () => {
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const result = resolveOverridableEntityEventBatchOverrides(batch);
const event = result.events[0] as {
properties: {
@@ -188,7 +188,7 @@ describe('sanitizeOverridableEntityEventBatch', () => {
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const result = resolveOverridableEntityEventBatchOverrides(batch);
const deletedRecord = (
result.events[0] as { properties: { before: Record<string, unknown> } }
@@ -196,146 +196,12 @@ describe('sanitizeOverridableEntityEventBatch', () => {
expect(deletedRecord.isVisible).toBe(false);
expect(deletedRecord).not.toHaveProperty('overrides');
expect(deletedRecord).not.toHaveProperty('isActive');
});
});
describe('isActive transitions', () => {
it('should drop create events when isActive is false', () => {
const after = makeViewFieldRecord({ isActive: false });
const batch = makeBatch('viewField', [
{
type: 'created',
metadataName: 'viewField',
recordId: 'vf-1',
properties: { after },
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(0);
});
it('should convert update to delete when entity is deactivated', () => {
const before = makeViewFieldRecord({ isActive: true });
const after = makeViewFieldRecord({ isActive: false });
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['isActive'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe('deleted');
expect(
(
result.events[0] as {
properties: { before: Record<string, unknown> };
}
).properties.before,
).not.toHaveProperty('isActive');
});
it('should convert update to create when entity is reactivated', () => {
const before = makeViewFieldRecord({ isActive: false });
const after = makeViewFieldRecord({
isActive: true,
overrides: { isVisible: false },
});
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['isActive'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe('created');
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
).properties.after;
expect(createdRecord.isVisible).toBe(false);
expect(createdRecord).not.toHaveProperty('overrides');
});
it('should drop update events when both before and after are inactive', () => {
const before = makeViewFieldRecord({ isActive: false });
const after = makeViewFieldRecord({
isActive: false,
overrides: { isVisible: false },
});
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['overrides'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(0);
});
it('should keep update events when both before and after are active', () => {
const before = makeViewFieldRecord({ isActive: true });
const after = makeViewFieldRecord({ isActive: true, size: 300 });
const batch = makeBatch('viewField', [
{
type: 'updated',
metadataName: 'viewField',
recordId: 'vf-1',
properties: {
updatedFields: ['size'],
diff: {},
before,
after,
},
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe('updated');
expect(deletedRecord).toHaveProperty('isActive', true);
});
});
describe('pageLayoutWidget (another overridable entity)', () => {
it('should resolve overrides for pageLayoutWidget', () => {
it('should resolve overrides for pageLayoutWidget and keep isActive', () => {
const after = {
id: 'plw-1',
workspaceId: 'ws-1',
@@ -364,7 +230,7 @@ describe('sanitizeOverridableEntityEventBatch', () => {
},
]);
const result = sanitizeOverridableEntityEventBatch(batch);
const result = resolveOverridableEntityEventBatchOverrides(batch);
const createdRecord = (
result.events[0] as { properties: { after: Record<string, unknown> } }
@@ -372,7 +238,7 @@ describe('sanitizeOverridableEntityEventBatch', () => {
expect(createdRecord.title).toBe('Overridden Title');
expect(createdRecord).not.toHaveProperty('overrides');
expect(createdRecord).not.toHaveProperty('isActive');
expect(createdRecord).toHaveProperty('isActive', true);
});
});
});
@@ -2,12 +2,7 @@ import { type AllMetadataName } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import {
type CreateMetadataEvent,
type DeleteMetadataEvent,
type MetadataEvent,
type UpdateMetadataEvent,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
import { type MetadataEvent } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
const OVERRIDABLE_ENTITY_METADATA_NAMES = new Set<AllMetadataName>([
'viewField',
@@ -19,7 +14,7 @@ const OVERRIDABLE_ENTITY_METADATA_NAMES = new Set<AllMetadataName>([
const resolveRecordOverrides = (
record: Record<string, unknown>,
): Record<string, unknown> => {
const { overrides, isActive, ...base } = record;
const { overrides, ...base } = record;
if (!isDefined(overrides)) {
return base;
@@ -28,89 +23,32 @@ const resolveRecordOverrides = (
return { ...base, ...(overrides as Record<string, unknown>) };
};
const sanitizeCreatedEvent = (
event: CreateMetadataEvent<AllMetadataName>,
): MetadataEvent | null => {
const after = event.properties.after as Record<string, unknown>;
const resolveEventOverrides = (event: MetadataEvent): MetadataEvent => {
const properties = { ...event.properties };
if (after.isActive === false) {
return null;
if ('before' in properties && isDefined(properties.before)) {
properties.before = resolveRecordOverrides(
properties.before as Record<string, unknown>,
) as typeof properties.before;
}
return {
...event,
properties: { after: resolveRecordOverrides(after) },
} as typeof event;
if ('after' in properties && isDefined(properties.after)) {
properties.after = resolveRecordOverrides(
properties.after as Record<string, unknown>,
) as typeof properties.after;
}
return { ...event, properties } as typeof event;
};
const sanitizeDeletedEvent = (
event: DeleteMetadataEvent<AllMetadataName>,
): MetadataEvent => {
const before = event.properties.before as Record<string, unknown>;
return {
...event,
properties: { before: resolveRecordOverrides(before) },
} as typeof event;
};
const sanitizeUpdatedEvent = (
event: UpdateMetadataEvent<AllMetadataName>,
): MetadataEvent | null => {
const before = event.properties.before as Record<string, unknown>;
const after = event.properties.after as Record<string, unknown>;
if (before.isActive === false && after.isActive === false) {
return null;
}
if (after.isActive === false) {
return {
type: 'deleted',
metadataName: event.metadataName,
recordId: event.recordId,
properties: { before: resolveRecordOverrides(before) },
} as MetadataEvent;
}
if (before.isActive === false) {
return {
type: 'created',
metadataName: event.metadataName,
recordId: event.recordId,
properties: { after: resolveRecordOverrides(after) },
} as MetadataEvent;
}
return {
...event,
properties: {
...event.properties,
before: resolveRecordOverrides(before),
after: resolveRecordOverrides(after),
},
} as typeof event;
};
export const sanitizeOverridableEntityEventBatch = (
export const resolveOverridableEntityEventBatchOverrides = (
metadataEventBatch: MetadataEventBatch,
): MetadataEventBatch => {
if (!OVERRIDABLE_ENTITY_METADATA_NAMES.has(metadataEventBatch.metadataName)) {
return metadataEventBatch;
}
const events = metadataEventBatch.events
.map((event) => {
switch (event.type) {
case 'created':
return sanitizeCreatedEvent(event);
case 'updated':
return sanitizeUpdatedEvent(event);
case 'deleted':
return sanitizeDeletedEvent(event);
}
})
.filter(isDefined);
const events = metadataEventBatch.events.map(resolveEventOverrides);
return { ...metadataEventBatch, events };
};