mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 10:13:01 +08:00
feat(bases): cascade-clean view configs when a property is deleted
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { stripPropertyFromViewConfig } from '../strip-property-from-view-config';
|
||||
|
||||
describe('stripPropertyFromViewConfig', () => {
|
||||
it('returns the config unchanged when no references exist', () => {
|
||||
const config = {
|
||||
sorts: [{ propertyId: 'p-other', direction: 'asc' as const }],
|
||||
filter: {
|
||||
op: 'and' as const,
|
||||
children: [{ propertyId: 'p-other', op: 'eq' as const, value: 1 }],
|
||||
},
|
||||
};
|
||||
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual(config);
|
||||
});
|
||||
|
||||
it('drops sort entries pointing at the deleted property', () => {
|
||||
const config = {
|
||||
sorts: [
|
||||
{ propertyId: 'p-deleted', direction: 'asc' as const },
|
||||
{ propertyId: 'p-keep', direction: 'desc' as const },
|
||||
],
|
||||
};
|
||||
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({
|
||||
sorts: [{ propertyId: 'p-keep', direction: 'desc' as const }],
|
||||
});
|
||||
});
|
||||
|
||||
it('prunes filter clauses pointing at the deleted property', () => {
|
||||
const config = {
|
||||
filter: {
|
||||
op: 'and' as const,
|
||||
children: [
|
||||
{ propertyId: 'p-deleted', op: 'eq' as const, value: 1 },
|
||||
{ propertyId: 'p-keep', op: 'eq' as const, value: 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({
|
||||
filter: {
|
||||
op: 'and' as const,
|
||||
children: [{ propertyId: 'p-keep', op: 'eq' as const, value: 2 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('drops nested groups that become empty after pruning', () => {
|
||||
const config = {
|
||||
filter: {
|
||||
op: 'and' as const,
|
||||
children: [
|
||||
{
|
||||
op: 'or' as const,
|
||||
children: [{ propertyId: 'p-deleted', op: 'eq' as const, value: 1 }],
|
||||
},
|
||||
{ propertyId: 'p-keep', op: 'eq' as const, value: 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({
|
||||
filter: {
|
||||
op: 'and' as const,
|
||||
children: [{ propertyId: 'p-keep', op: 'eq' as const, value: 2 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('clears kanban fields when groupByPropertyId matches', () => {
|
||||
const config = {
|
||||
groupByPropertyId: 'p-deleted',
|
||||
hiddenChoiceIds: ['c1'],
|
||||
choiceOrder: ['c1', 'c2'],
|
||||
sorts: [{ propertyId: 'p-keep', direction: 'asc' as const }],
|
||||
};
|
||||
expect(stripPropertyFromViewConfig(config, 'p-deleted')).toEqual({
|
||||
sorts: [{ propertyId: 'p-keep', direction: 'asc' as const }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import { executeTx } from '@docmost/db/utils';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { stripPropertyFromViewConfig } from './strip-property-from-view-config';
|
||||
import { CreatePropertyDto } from '../dto/create-property.dto';
|
||||
import {
|
||||
UpdatePropertyDto,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
BasePropertyReorderedEvent,
|
||||
BasePropertyUpdatedEvent,
|
||||
BaseSchemaBumpedEvent,
|
||||
BaseViewUpdatedEvent,
|
||||
} from '../events/base-events';
|
||||
import { processBaseTypeConversion } from '../tasks/base-type-conversion.task';
|
||||
import { FormulaService } from '../formula/formula.service';
|
||||
@@ -67,6 +70,7 @@ export class BasePropertyService {
|
||||
private readonly basePropertyRepo: BasePropertyRepo,
|
||||
private readonly baseRowRepo: BaseRowRepo,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly baseViewRepo: BaseViewRepo,
|
||||
@InjectQueue(QueueName.BASE_QUEUE) private readonly baseQueue: Queue,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly formulaService: FormulaService,
|
||||
@@ -517,11 +521,55 @@ export class BasePropertyService {
|
||||
// Soft-delete so queries filter the property out immediately, then
|
||||
// enqueue cell-gc to scrub cell keys and hard-delete. If the enqueue
|
||||
// fails, revert the soft-delete so the property isn't orphaned.
|
||||
// In the same transaction, strip the deleted property's id out of
|
||||
// every view's config (sorts, filters, groupBy) so views don't render
|
||||
// dangling references after the delete commits.
|
||||
const updatedViewIds: string[] = [];
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.basePropertyRepo.softDelete(dto.propertyId, trx);
|
||||
await this.baseRepo.bumpSchemaVersion(dto.pageId, trx);
|
||||
|
||||
const views = await this.baseViewRepo.findByPageId(dto.pageId, {
|
||||
workspaceId,
|
||||
trx,
|
||||
});
|
||||
for (const view of views) {
|
||||
const before = (view.config ?? {}) as Record<string, unknown>;
|
||||
const next = stripPropertyFromViewConfig(
|
||||
view.config as any,
|
||||
dto.propertyId,
|
||||
);
|
||||
const after = next as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(before).length === Object.keys(after).length &&
|
||||
Object.keys(before).every((k) => k in after) &&
|
||||
JSON.stringify(before) === JSON.stringify(after)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await this.baseViewRepo.updateView(
|
||||
view.id,
|
||||
{ config: next as any },
|
||||
{ workspaceId, trx },
|
||||
);
|
||||
updatedViewIds.push(view.id);
|
||||
}
|
||||
});
|
||||
|
||||
for (const viewId of updatedViewIds) {
|
||||
const fresh = await this.baseViewRepo.findById(viewId, { workspaceId });
|
||||
if (fresh) {
|
||||
const event: BaseViewUpdatedEvent = {
|
||||
pageId: dto.pageId,
|
||||
workspaceId,
|
||||
actorId: actorId ?? null,
|
||||
requestId: dto.requestId ?? null,
|
||||
view: fresh,
|
||||
};
|
||||
this.eventEmitter.emit(EventName.BASE_VIEW_UPDATED, event);
|
||||
}
|
||||
}
|
||||
|
||||
const payload: IBaseCellGcJob = {
|
||||
pageId: dto.pageId,
|
||||
propertyId: dto.propertyId,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ViewConfig } from '../base.schemas';
|
||||
|
||||
type FilterCondition = {
|
||||
propertyId: string;
|
||||
op: string;
|
||||
value?: unknown;
|
||||
};
|
||||
type FilterGroup = {
|
||||
op: 'and' | 'or';
|
||||
children: Array<FilterCondition | FilterGroup>;
|
||||
};
|
||||
type FilterNode = FilterCondition | FilterGroup;
|
||||
|
||||
function isGroup(node: FilterNode): node is FilterGroup {
|
||||
return 'children' in node;
|
||||
}
|
||||
|
||||
function pruneFilter(
|
||||
node: FilterNode,
|
||||
propertyId: string,
|
||||
): FilterNode | null {
|
||||
if (isGroup(node)) {
|
||||
const kept = node.children
|
||||
.map((c) => pruneFilter(c, propertyId))
|
||||
.filter((c): c is FilterNode => c !== null);
|
||||
return kept.length === 0 ? null : { op: node.op, children: kept };
|
||||
}
|
||||
return node.propertyId === propertyId ? null : node;
|
||||
}
|
||||
|
||||
export function stripPropertyFromViewConfig(
|
||||
config: ViewConfig | undefined | null,
|
||||
propertyId: string,
|
||||
): ViewConfig {
|
||||
if (!config) return {};
|
||||
const next: Record<string, unknown> = { ...config };
|
||||
|
||||
if (config.sorts) {
|
||||
const sorts = config.sorts.filter((s) => s.propertyId !== propertyId);
|
||||
if (sorts.length > 0) next.sorts = sorts;
|
||||
else delete next.sorts;
|
||||
}
|
||||
|
||||
if (config.filter) {
|
||||
const pruned = pruneFilter(config.filter, propertyId);
|
||||
if (pruned) next.filter = pruned;
|
||||
else delete next.filter;
|
||||
}
|
||||
|
||||
if (config.groupByPropertyId === propertyId) {
|
||||
delete next.groupByPropertyId;
|
||||
delete next.hiddenChoiceIds;
|
||||
delete next.choiceOrder;
|
||||
}
|
||||
|
||||
return next as ViewConfig;
|
||||
}
|
||||
Reference in New Issue
Block a user