diff --git a/apps/server/src/core/base/processors/base-queue.processor.ts b/apps/server/src/core/base/processors/base-queue.processor.ts index 27918eb8c..6bf623036 100644 --- a/apps/server/src/core/base/processors/base-queue.processor.ts +++ b/apps/server/src/core/base/processors/base-queue.processor.ts @@ -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: { diff --git a/apps/server/src/core/base/services/__tests__/strip-property-from-view-config.spec.ts b/apps/server/src/core/base/services/__tests__/strip-property-from-view-config.spec.ts index d9ab7a78f..fc85e80ac 100644 --- a/apps/server/src/core/base/services/__tests__/strip-property-from-view-config.spec.ts +++ b/apps/server/src/core/base/services/__tests__/strip-property-from-view-config.spec.ts @@ -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'], + }); + }); +}); diff --git a/apps/server/src/core/base/services/base-property.service.ts b/apps/server/src/core/base/services/base-property.service.ts index 7997faf91..b62a0c991 100644 --- a/apps/server/src/core/base/services/base-property.service.ts +++ b/apps/server/src/core/base/services/base-property.service.ts @@ -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 { + 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, diff --git a/apps/server/src/core/base/services/strip-property-from-view-config.ts b/apps/server/src/core/base/services/strip-property-from-view-config.ts index 3b397d069..e6f708460 100644 --- a/apps/server/src/core/base/services/strip-property-from-view-config.ts +++ b/apps/server/src/core/base/services/strip-property-from-view-config.ts @@ -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 = { ...config }; + delete next.groupByPropertyId; + delete next.hiddenChoiceIds; + delete next.choiceOrder; + return next as ViewConfig; +}