feat(bases): clear kanban grouping when its property changes to a non-groupable type

This commit is contained in:
Philipinho
2026-05-24 16:26:57 +01:00
parent 2acd55c38d
commit d1ebeffe19
4 changed files with 136 additions and 2 deletions
@@ -8,6 +8,8 @@ import { executeTx } from '@docmost/db/utils';
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { BasePropertyService } from '../services/base-property.service';
import { BasePropertyTypeValue } from '../base.schemas';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import {
IBaseCellGcJob,
@@ -39,6 +41,7 @@ export class BaseQueueProcessor
private readonly baseRowRepo: BaseRowRepo,
private readonly basePropertyRepo: BasePropertyRepo,
private readonly baseRepo: BaseRepo,
private readonly basePropertyService: BasePropertyService,
private readonly eventEmitter: EventEmitter2,
private readonly formulaLock: FormulaLockService,
) {
@@ -95,6 +98,13 @@ export class BaseQueueProcessor
schemaVersion,
data.actorId,
);
await this.basePropertyService.clearKanbanGroupingIfNotGroupable(
data.pageId,
data.propertyId,
data.toType as BasePropertyTypeValue,
data.workspaceId,
data.actorId ?? null,
);
return summary;
}
case QueueJob.BASE_CELL_GC: {
@@ -1,4 +1,7 @@
import { stripPropertyFromViewConfig } from '../strip-property-from-view-config';
import {
clearKanbanGroupingFromViewConfig,
stripPropertyFromViewConfig,
} from '../strip-property-from-view-config';
describe('stripPropertyFromViewConfig', () => {
it('returns the config unchanged when no references exist', () => {
@@ -124,3 +127,45 @@ describe('stripPropertyFromViewConfig', () => {
});
});
});
describe('clearKanbanGroupingFromViewConfig', () => {
it("returns the config unchanged (by reference) when groupByPropertyId doesn't match", () => {
const config = { groupByPropertyId: 'p-other' };
expect(clearKanbanGroupingFromViewConfig(config, 'p-here')).toBe(config);
});
it('returns {} when config is null or undefined', () => {
expect(clearKanbanGroupingFromViewConfig(null, 'p1')).toEqual({});
expect(clearKanbanGroupingFromViewConfig(undefined, 'p1')).toEqual({});
});
it('clears kanban fields when groupByPropertyId matches', () => {
const result = clearKanbanGroupingFromViewConfig(
{
groupByPropertyId: 'p1',
hiddenChoiceIds: ['c1'],
choiceOrder: ['c1'],
sorts: [{ propertyId: 'p2', direction: 'asc' as const }],
},
'p1',
);
expect(result).toEqual({
sorts: [{ propertyId: 'p2', direction: 'asc' as const }],
});
});
it('preserves non-kanban fields untouched when clearing', () => {
const result = clearKanbanGroupingFromViewConfig(
{
groupByPropertyId: 'p1',
filter: { op: 'and' as const, children: [] },
visiblePropertyIds: ['p2'],
},
'p1',
);
expect(result).toEqual({
filter: { op: 'and' as const, children: [] },
visiblePropertyIds: ['p2'],
});
});
});
@@ -17,7 +17,10 @@ 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 {
clearKanbanGroupingFromViewConfig,
stripPropertyFromViewConfig,
} from './strip-property-from-view-config';
import { CreatePropertyDto } from '../dto/create-property.dto';
import {
UpdatePropertyDto,
@@ -374,6 +377,13 @@ export class BasePropertyService {
schemaVersion,
};
this.eventEmitter.emit(EventName.BASE_SCHEMA_BUMPED, bumpEvent);
await this.clearKanbanGroupingIfNotGroupable(
dto.pageId,
dto.propertyId,
newType,
workspaceId,
actorId ?? null,
);
return this.loadAndEmit(dto, workspaceId, actorId, null);
}
@@ -452,6 +462,55 @@ export class BasePropertyService {
}
}
/*
* Post-type-conversion cleanup: when a property's type flips to something
* not groupable (anything other than select/status), drop the kanban
* grouping fields from any view rooted on it. Called from BOTH the inline
* path here and the BullMQ processor after the pending→live swap, so
* `view.config` stays coherent with the property's actual type. Each
* touched view re-emits `base.view.updated` so other clients see it.
*/
async clearKanbanGroupingIfNotGroupable(
pageId: string,
propertyId: string,
newType: BasePropertyTypeValue,
workspaceId: string,
actorId: string | null,
): Promise<void> {
if (
newType === BasePropertyType.SELECT ||
newType === BasePropertyType.STATUS
) {
return;
}
const views = await this.baseViewRepo.findByPageId(pageId, {
workspaceId,
});
for (const view of views) {
const before = (view.config ?? {}) as ViewConfig;
const after = clearKanbanGroupingFromViewConfig(before, propertyId);
if (after === before) continue;
await this.baseViewRepo.updateView(
view.id,
// `config` column is typed `Json` by Kysely; ViewConfig is a Zod
// inferred shape that isn't structurally assignable to `Json`.
{ config: after as any },
{ workspaceId },
);
const fresh = await this.baseViewRepo.findById(view.id, { workspaceId });
if (fresh) {
const event: BaseViewUpdatedEvent = {
pageId,
workspaceId,
actorId,
requestId: null,
view: fresh,
};
this.eventEmitter.emit(EventName.BASE_VIEW_UPDATED, event);
}
}
}
private async loadAndEmit(
dto: UpdatePropertyDto,
workspaceId: string,
@@ -79,3 +79,23 @@ export function stripPropertyFromViewConfig(
return next as ViewConfig;
}
/*
* Narrower sibling of `stripPropertyFromViewConfig`. Used after a property's
* type changes to something no longer groupable (e.g. select → text): the
* property still exists, so we leave sorts/filters/visibility alone, and only
* clear the kanban grouping fields rooted on it. Returns the input by
* reference when nothing matches so callers can skip the write.
*/
export function clearKanbanGroupingFromViewConfig(
config: ViewConfig | undefined | null,
propertyId: string,
): ViewConfig {
if (!config) return {};
if (config.groupByPropertyId !== propertyId) return config;
const next: Record<string, unknown> = { ...config };
delete next.groupByPropertyId;
delete next.hiddenChoiceIds;
delete next.choiceOrder;
return next as ViewConfig;
}