feat: improve kanban grouping & data materialization (#14393)

fix #13512 
fix #13255
fix #9743 

#### PR Dependency Tree


* **PR #14393** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced Kanban view grouping support for additional property types:
checkboxes, select fields, multi-select fields, members, and created-by
information.
* Improved drag-and-drop visual feedback with more precise drop
indicators in Kanban views.

* **Bug Fixes**
* Refined grouping logic to ensure only compatible properties appear in
group-by options.
* Enhanced column visibility and ordering consistency when managing
Kanban views.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-08 03:48:12 +08:00
committed by GitHub
parent 31e11b2563
commit 8192a492d9
16 changed files with 795 additions and 81 deletions
@@ -3,17 +3,9 @@ import { kanbanViewModel } from './kanban/index.js';
import { tableViewModel } from './table/index.js';
export const viewConverts = [
createViewConvert(tableViewModel, kanbanViewModel, data => {
if (data.groupBy) {
return {
filter: data.filter,
groupBy: data.groupBy,
};
}
return {
filter: data.filter,
};
}),
createViewConvert(tableViewModel, kanbanViewModel, data => ({
filter: data.filter,
})),
createViewConvert(kanbanViewModel, tableViewModel, data => ({
filter: data.filter,
groupBy: data.groupBy,
@@ -2,9 +2,9 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
import type { FilterGroup } from '../../core/filter/types.js';
import { defaultGroupBy, getGroupByService, t } from '../../core/index.js';
import type { Sort } from '../../core/sort/types.js';
import { type BasicViewDataType, viewType } from '../../core/view/data-view.js';
import { resolveKanbanGroupBy } from './group-by-utils.js';
import { KanbanSingleView } from './kanban-view-manager.js';
export const kanbanViewType = viewType('kanban');
@@ -34,41 +34,16 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
defaultName: 'Kanban View',
dataViewManager: KanbanSingleView,
defaultData: viewManager => {
const groupByService = getGroupByService(viewManager.dataSource);
const columns = viewManager.dataSource.properties$.value;
const allowList = columns.filter(columnId => {
const dataType = viewManager.dataSource.propertyDataTypeGet(columnId);
return dataType && !!groupByService?.matcher.match(dataType);
});
const getWeight = (columnId: string) => {
const dataType = viewManager.dataSource.propertyDataTypeGet(columnId);
if (!dataType || t.string.is(dataType) || t.richText.is(dataType)) {
return 0;
}
if (t.tag.is(dataType)) {
return 3;
}
if (t.array.is(dataType)) {
return 2;
}
return 1;
};
const columnId = allowList.sort((a, b) => getWeight(b) - getWeight(a))[0];
if (!columnId) {
const groupBy = resolveKanbanGroupBy(viewManager.dataSource);
if (!groupBy) {
throw new BlockSuiteError(
ErrorCode.DatabaseBlockError,
'no groupable column found'
);
}
const type = viewManager.dataSource.propertyTypeGet(columnId);
const meta = type && viewManager.dataSource.propertyMetaGet(type);
const data = viewManager.dataSource.propertyDataGet(columnId);
if (!columnId || !meta || !data) {
throw new BlockSuiteError(
ErrorCode.DatabaseBlockError,
'not implement yet'
);
}
const columns = viewManager.dataSource.properties$.value;
return {
columns: columns.map(id => ({
id: id,
@@ -78,7 +53,7 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
op: 'and',
conditions: [],
},
groupBy: defaultGroupBy(viewManager.dataSource, meta, columnId, data),
groupBy,
header: {
titleColumn: viewManager.dataSource.properties$.value.find(
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
@@ -0,0 +1,142 @@
import { nanoid } from '@blocksuite/store';
import type { GroupBy } from '../../core/common/types.js';
import { getTagColor } from '../../core/component/tags/colors.js';
import type { DataSource } from '../../core/data-source/base.js';
import { defaultGroupBy } from '../../core/group-by/default.js';
import { getGroupByService } from '../../core/group-by/matcher.js';
type KanbanGroupCapability = 'mutable' | 'immutable' | 'none';
const KANBAN_DEFAULT_STATUS_OPTIONS = ['Todo', 'In Progress', 'Done'];
const SHOW_EMPTY_GROUPS_BY_DEFAULT = new Set(['select', 'multi-select']);
export const getKanbanDefaultHideEmpty = (groupName?: string): boolean => {
return !groupName || !SHOW_EMPTY_GROUPS_BY_DEFAULT.has(groupName);
};
const getKanbanGroupCapability = (
dataSource: DataSource,
propertyId: string
): KanbanGroupCapability => {
const type = dataSource.propertyTypeGet(propertyId);
if (!type) {
return 'none';
}
const meta = dataSource.propertyMetaGet(type);
const kanbanGroup = meta?.config.kanbanGroup;
if (!kanbanGroup?.enabled) {
return 'none';
}
return kanbanGroup.mutable ? 'mutable' : 'immutable';
};
const hasMatchingGroupBy = (dataSource: DataSource, propertyId: string) => {
const dataType = dataSource.propertyDataTypeGet(propertyId);
if (!dataType) {
return false;
}
const groupByService = getGroupByService(dataSource);
return !!groupByService?.matcher.match(dataType);
};
const createGroupByFromColumn = (
dataSource: DataSource,
columnId: string
): GroupBy | undefined => {
const type = dataSource.propertyTypeGet(columnId);
if (!type) {
return;
}
const meta = dataSource.propertyMetaGet(type);
if (!meta) {
return;
}
return defaultGroupBy(
dataSource,
meta,
columnId,
dataSource.propertyDataGet(columnId)
);
};
export const canGroupable = (dataSource: DataSource, propertyId: string) => {
return (
getKanbanGroupCapability(dataSource, propertyId) !== 'none' &&
hasMatchingGroupBy(dataSource, propertyId)
);
};
export const pickKanbanGroupColumn = (
dataSource: DataSource,
propertyIds: string[] = dataSource.properties$.value
): string | undefined => {
let immutableFallback: string | undefined;
for (const propertyId of propertyIds) {
const capability = getKanbanGroupCapability(dataSource, propertyId);
if (capability === 'none' || !hasMatchingGroupBy(dataSource, propertyId)) {
continue;
}
if (capability === 'mutable') {
return propertyId;
}
immutableFallback ??= propertyId;
}
return immutableFallback;
};
export const ensureKanbanGroupColumn = (
dataSource: DataSource
): string | undefined => {
const columnId = pickKanbanGroupColumn(dataSource);
if (columnId) {
return columnId;
}
const statusId = dataSource.propertyAdd('end', {
type: 'select',
name: 'Status',
});
if (!statusId) {
return;
}
dataSource.propertyDataSet(statusId, {
options: KANBAN_DEFAULT_STATUS_OPTIONS.map(value => ({
id: nanoid(),
value,
color: getTagColor(),
})),
});
return statusId;
};
export const resolveKanbanGroupBy = (
dataSource: DataSource,
current?: GroupBy
): GroupBy | undefined => {
const keepColumnId =
current?.columnId && canGroupable(dataSource, current.columnId)
? current.columnId
: undefined;
const columnId = keepColumnId ?? ensureKanbanGroupColumn(dataSource);
if (!columnId) {
return;
}
const next = createGroupByFromColumn(dataSource, columnId);
if (!next) {
return;
}
return {
...next,
sort: current?.sort,
hideEmpty: current?.hideEmpty ?? getKanbanDefaultHideEmpty(next.name),
};
};
@@ -17,7 +17,52 @@ import {
import { fromJson } from '../../core/property/utils';
import { PropertyBase } from '../../core/view-manager/property.js';
import { SingleViewBase } from '../../core/view-manager/single-view.js';
import type { KanbanViewData } from './define.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import type { KanbanViewColumn, KanbanViewData } from './define.js';
import {
getKanbanDefaultHideEmpty,
resolveKanbanGroupBy,
} from './group-by-utils.js';
const materializeColumnsByPropertyIds = (
columns: KanbanViewColumn[],
propertyIds: string[]
) => {
const needShow = new Set(propertyIds);
const orderedColumns: KanbanViewColumn[] = [];
for (const column of columns) {
if (needShow.has(column.id)) {
orderedColumns.push(column);
needShow.delete(column.id);
}
}
for (const id of needShow) {
orderedColumns.push({ id });
}
return orderedColumns;
};
export const materializeKanbanColumns = (
columns: KanbanViewColumn[],
propertyIds: string[]
) => {
const nextColumns = materializeColumnsByPropertyIds(columns, propertyIds);
const unchanged =
columns.length === nextColumns.length &&
columns.every((column, index) => {
const nextColumn = nextColumns[index];
return (
nextColumn != null &&
column.id === nextColumn.id &&
column.hide === nextColumn.hide
);
});
return unchanged ? columns : nextColumns;
};
export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
propertiesRaw$ = computed(() => {
@@ -61,16 +106,27 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
);
groupBy$ = computed(() => {
return this.data$.value?.groupBy;
const groupBy = this.data$.value?.groupBy;
if (!groupBy || groupBy.hideEmpty != null) {
return groupBy;
}
return {
...groupBy,
hideEmpty: getKanbanDefaultHideEmpty(groupBy.name),
};
});
groupTrait = this.traitSet(
groupTraitKey,
new GroupTrait(this.groupBy$, this, {
groupBySet: groupBy => {
const nextGroupBy = resolveKanbanGroupBy(
this.manager.dataSource,
groupBy
);
this.dataUpdate(() => {
return {
groupBy: groupBy,
groupBy: nextGroupBy,
};
});
},
@@ -200,6 +256,23 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return this.view?.mode ?? 'kanban';
}
private materializeColumns() {
const view = this.view;
if (!view) {
return;
}
const nextColumns = materializeKanbanColumns(
view.columns,
this.dataSource.properties$.value
);
if (nextColumns === view.columns) {
return;
}
this.dataUpdate(() => ({ columns: nextColumns }));
}
get view() {
return this.data$.value;
}
@@ -289,6 +362,13 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
propertyGetOrCreate(columnId: string): KanbanColumn {
return new KanbanColumn(this, columnId);
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
// Materialize view columns on view activation so newly added properties
// can participate in hide/order operations in kanban.
this.materializeColumns();
}
}
type KanbanColumnData = KanbanViewData['columns'][number];
@@ -190,7 +190,7 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
div.className = 'with-data-view-css-variable';
div.style.width = `${card.getBoundingClientRect().width}px`;
div.style.position = 'fixed';
// div.style.pointerEvents = 'none';
div.style.pointerEvents = 'none';
div.style.transform = 'rotate(-3deg)';
div.style.left = `${x}px`;
div.style.top = `${y}px`;
@@ -209,8 +209,12 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
};
const createDropPreview = () => {
const div = document.createElement('div');
div.style.height = '2px';
div.style.borderRadius = '1px';
div.dataset.isDropPreview = 'true';
div.style.pointerEvents = 'none';
div.style.position = 'fixed';
div.style.zIndex = '9999';
div.style.height = '3px';
div.style.borderRadius = '2px';
div.style.backgroundColor = 'var(--affine-primary-color)';
div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
return {
@@ -219,19 +223,50 @@ const createDropPreview = () => {
self: KanbanCard | undefined,
card?: KanbanCard
) {
const target = card ?? group.querySelector('.add-card');
if (!target) {
console.error('`target` is not found');
return;
}
if (target.previousElementSibling === self || target === self) {
if (card === self) {
div.remove();
return;
}
if (target.previousElementSibling === div) {
if (!card) {
const cards = Array.from(
group.querySelectorAll('affine-data-view-kanban-card')
);
const lastCard = cards[cards.length - 1];
if (lastCard === self) {
div.remove();
return;
}
}
let rect: DOMRect | undefined;
let y = 0;
if (card) {
rect = card.getBoundingClientRect();
y = rect.top;
} else {
const addCard = group.querySelector('.add-card');
if (addCard instanceof HTMLElement) {
rect = addCard.getBoundingClientRect();
y = rect.top;
}
}
if (!rect) {
const body = group.querySelector('.group-body');
if (body instanceof HTMLElement) {
rect = body.getBoundingClientRect();
y = rect.bottom;
}
}
if (!rect) {
div.remove();
return;
}
target.insertAdjacentElement('beforebegin', div);
document.body.append(div);
div.style.left = `${Math.round(rect.left)}px`;
div.style.top = `${Math.round(y - 2)}px`;
div.style.width = `${Math.round(rect.width)}px`;
},
remove() {
div.remove();
@@ -11,6 +11,7 @@ import { html } from 'lit/static-html.js';
import { groupTraitKey } from '../../../core/group-by/trait.js';
import type { SingleView } from '../../../core/index.js';
import { canGroupable } from '../group-by-utils.js';
const styles = css`
affine-data-view-kanban-header {
@@ -43,7 +44,12 @@ export class KanbanHeader extends SignalWatcher(
popMenu(popupTargetFromElement(e.target as HTMLElement), {
options: {
items: this.view.properties$.value
.filter(column => column.id !== groupTrait.property$.value?.id)
.filter(column => {
if (column.id === groupTrait.property$.value?.id) {
return false;
}
return canGroupable(this.view.manager.dataSource, column.id);
})
.map(column => {
return menu.action({
name: column.name$.value,