diff --git a/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts b/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts new file mode 100644 index 0000000000..3ecce8fc4d --- /dev/null +++ b/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts @@ -0,0 +1,459 @@ +import { signal } from '@preact/signals-core'; +import { describe, expect, it, vi } from 'vitest'; + +import type { GroupBy } from '../core/common/types.js'; +import type { DataSource } from '../core/data-source/base.js'; +import { groupByMatchers } from '../core/group-by/define.js'; +import { t } from '../core/logical/type-presets.js'; +import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js'; +import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js'; +import { selectPropertyModelConfig } from '../property-presets/select/define.js'; +import { textPropertyModelConfig } from '../property-presets/text/define.js'; +import { + canGroupable, + ensureKanbanGroupColumn, + pickKanbanGroupColumn, + resolveKanbanGroupBy, +} from '../view-presets/kanban/group-by-utils.js'; +import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js'; +import type { KanbanCard } from '../view-presets/kanban/pc/card.js'; +import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js'; +import type { KanbanGroup } from '../view-presets/kanban/pc/group.js'; + +type Column = { + id: string; + type: string; + data?: Record; +}; + +type TestPropertyMeta = { + type: string; + config: { + kanbanGroup?: { + enabled: boolean; + mutable?: boolean; + }; + propertyData: { + default: () => Record; + }; + jsonValue: { + type: (options: { + data: Record; + dataSource: DataSource; + }) => unknown; + }; + }; +}; + +type MockDataSource = { + properties$: ReturnType>; + provider: { + getAll: () => Map; + }; + serviceGetOrCreate: (key: unknown, create: () => unknown) => unknown; + propertyTypeGet: (propertyId: string) => string | undefined; + propertyMetaGet: (type: string) => TestPropertyMeta | undefined; + propertyDataGet: (propertyId: string) => Record; + propertyDataTypeGet: (propertyId: string) => unknown; + propertyAdd: ( + _position: unknown, + ops?: { + type?: string; + } + ) => string; + propertyDataSet: (propertyId: string, data: Record) => void; +}; + +const asDataSource = (dataSource: object): DataSource => + dataSource as DataSource; + +const toTestMeta = >( + type: string, + config: { + kanbanGroup?: { + enabled: boolean; + mutable?: boolean; + }; + propertyData: { + default: () => TData; + }; + jsonValue: { + type: (options: { data: TData; dataSource: DataSource }) => unknown; + }; + } +): TestPropertyMeta => ({ + type, + config: { + kanbanGroup: config.kanbanGroup, + propertyData: { + default: () => config.propertyData.default(), + }, + jsonValue: { + type: ({ data, dataSource }) => + config.jsonValue.type({ + data: data as TData, + dataSource, + }), + }, + }, +}); + +const immutableBooleanMeta = toTestMeta('immutable-boolean', { + ...checkboxPropertyModelConfig.config, + kanbanGroup: { + enabled: true, + mutable: false, + }, +}); + +const createMockDataSource = (columns: Column[]): MockDataSource => { + const properties$ = signal(columns.map(column => column.id)); + const typeById = new Map(columns.map(column => [column.id, column.type])); + const dataById = new Map( + columns.map(column => [column.id, column.data ?? {}]) + ); + const services = new Map(); + + const metaEntries: Array<[string, TestPropertyMeta]> = [ + [ + checkboxPropertyModelConfig.type, + toTestMeta( + checkboxPropertyModelConfig.type, + checkboxPropertyModelConfig.config + ), + ], + [ + selectPropertyModelConfig.type, + toTestMeta( + selectPropertyModelConfig.type, + selectPropertyModelConfig.config + ), + ], + [ + multiSelectPropertyModelConfig.type, + toTestMeta( + multiSelectPropertyModelConfig.type, + multiSelectPropertyModelConfig.config + ), + ], + [ + textPropertyModelConfig.type, + toTestMeta(textPropertyModelConfig.type, textPropertyModelConfig.config), + ], + [immutableBooleanMeta.type, immutableBooleanMeta], + ]; + const metaByType = new Map(metaEntries); + + const asRecord = (value: unknown): Record => + typeof value === 'object' && value != null + ? (value as Record) + : {}; + + let autoColumnId = 0; + + const dataSource = { + properties$, + provider: { + getAll: () => new Map(), + }, + serviceGetOrCreate: (key: unknown, create: () => unknown) => { + if (!services.has(key)) { + services.set(key, create()); + } + return services.get(key); + }, + propertyTypeGet: (propertyId: string) => typeById.get(propertyId), + propertyMetaGet: (type: string) => metaByType.get(type), + propertyDataGet: (propertyId: string) => asRecord(dataById.get(propertyId)), + propertyDataTypeGet: (propertyId: string) => { + const type = typeById.get(propertyId); + if (!type) { + return; + } + const meta = metaByType.get(type); + if (!meta) { + return; + } + return meta.config.jsonValue.type({ + data: asRecord(dataById.get(propertyId)), + dataSource: asDataSource(dataSource), + }); + }, + propertyAdd: ( + _position: unknown, + ops?: { + type?: string; + } + ) => { + const type = ops?.type ?? selectPropertyModelConfig.type; + const id = `auto-${++autoColumnId}`; + const meta = metaByType.get(type); + const data = meta?.config.propertyData.default() ?? {}; + + typeById.set(id, type); + dataById.set(id, data); + properties$.value = [...properties$.value, id]; + return id; + }, + propertyDataSet: (propertyId: string, data: Record) => { + dataById.set(propertyId, data); + }, + }; + + return dataSource; +}; + +const createDragController = () => { + type DragLogic = ConstructorParameters[0]; + return new KanbanDragController({} as DragLogic); +}; + +describe('kanban', () => { + describe('group-by define', () => { + it('boolean group should not include ungroup bucket', () => { + const booleanGroup = groupByMatchers.find( + group => group.name === 'boolean' + ); + expect(booleanGroup).toBeDefined(); + + const keys = booleanGroup! + .defaultKeys(t.boolean.instance()) + .map(group => group.key); + + expect(keys).toEqual(['true', 'false']); + }); + + it('boolean group should fallback invalid values to false bucket', () => { + const booleanGroup = groupByMatchers.find( + group => group.name === 'boolean' + ); + expect(booleanGroup).toBeDefined(); + + const groups = booleanGroup!.valuesGroup(undefined, t.boolean.instance()); + expect(groups).toEqual([{ key: 'false', value: false }]); + }); + }); + + describe('columns materialization', () => { + it('appends missing properties while preserving existing order and state', () => { + const columns = [{ id: 'status', hide: true }, { id: 'title' }]; + + const next = materializeKanbanColumns(columns, [ + 'title', + 'status', + 'date', + ]); + + expect(next).toEqual([ + { id: 'status', hide: true }, + { id: 'title' }, + { id: 'date' }, + ]); + }); + + it('drops stale columns that no longer exist in data source', () => { + const columns = [{ id: 'title' }, { id: 'removed', hide: true }]; + + const next = materializeKanbanColumns(columns, ['title']); + + expect(next).toEqual([{ id: 'title' }]); + }); + + it('returns original reference when columns are already materialized', () => { + const columns = [{ id: 'title' }, { id: 'status', hide: true }]; + + const next = materializeKanbanColumns(columns, ['title', 'status']); + + expect(next).toBe(columns); + }); + }); + + describe('drag indicator', () => { + it('shows drop preview when insert position exists', () => { + const controller = createDragController(); + const position = { + group: {} as KanbanGroup, + position: 'end' as const, + }; + controller.getInsertPosition = vi.fn().mockReturnValue(position); + + const displaySpy = vi.spyOn(controller.dropPreview, 'display'); + const removeSpy = vi.spyOn(controller.dropPreview, 'remove'); + + const result = controller.showIndicator({} as MouseEvent, undefined); + + expect(result).toBe(position); + expect(displaySpy).toHaveBeenCalledWith( + position.group, + undefined, + undefined + ); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it('removes drop preview when insert position does not exist', () => { + const controller = createDragController(); + controller.getInsertPosition = vi.fn().mockReturnValue(undefined); + + const displaySpy = vi.spyOn(controller.dropPreview, 'display'); + const removeSpy = vi.spyOn(controller.dropPreview, 'remove'); + + const result = controller.showIndicator({} as MouseEvent, undefined); + + expect(result).toBeUndefined(); + expect(displaySpy).not.toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalledOnce(); + }); + + it('forwards hovered card to drop preview for precise insertion cursor', () => { + const controller = createDragController(); + const hoveredCard = document.createElement( + 'affine-data-view-kanban-card' + ) as KanbanCard; + const positionCard = document.createElement( + 'affine-data-view-kanban-card' + ) as KanbanCard; + const position = { + group: {} as KanbanGroup, + card: positionCard, + position: { before: true, id: 'card-id' } as const, + }; + controller.getInsertPosition = vi.fn().mockReturnValue(position); + + const displaySpy = vi.spyOn(controller.dropPreview, 'display'); + + controller.showIndicator({} as MouseEvent, hoveredCard); + + expect(displaySpy).toHaveBeenCalledWith( + position.group, + hoveredCard, + position.card + ); + }); + }); + + describe('group-by utils', () => { + it('allows only kanban-enabled property types to group', () => { + const dataSource = createMockDataSource([ + { id: 'text', type: textPropertyModelConfig.type }, + { id: 'select', type: selectPropertyModelConfig.type }, + { id: 'multi-select', type: multiSelectPropertyModelConfig.type }, + { id: 'checkbox', type: checkboxPropertyModelConfig.type }, + ]); + + expect(canGroupable(asDataSource(dataSource), 'text')).toBe(false); + expect(canGroupable(asDataSource(dataSource), 'select')).toBe(true); + expect(canGroupable(asDataSource(dataSource), 'multi-select')).toBe(true); + expect(canGroupable(asDataSource(dataSource), 'checkbox')).toBe(true); + }); + + it('prefers mutable group column over immutable ones', () => { + const dataSource = createMockDataSource([ + { + id: 'immutable-bool', + type: 'immutable-boolean', + }, + { + id: 'checkbox', + type: checkboxPropertyModelConfig.type, + }, + ]); + + expect(pickKanbanGroupColumn(asDataSource(dataSource))).toBe('checkbox'); + }); + + it('creates default status select column when no groupable column exists', () => { + const dataSource = createMockDataSource([ + { + id: 'text', + type: textPropertyModelConfig.type, + }, + ]); + + const statusColumnId = ensureKanbanGroupColumn(asDataSource(dataSource)); + + expect(statusColumnId).toBeTruthy(); + expect(dataSource.propertyTypeGet(statusColumnId!)).toBe( + selectPropertyModelConfig.type + ); + const options = + ( + dataSource.propertyDataGet(statusColumnId!) as { + options?: { value: string }[]; + } + ).options ?? []; + expect(options.map(option => option.value)).toEqual([ + 'Todo', + 'In Progress', + 'Done', + ]); + }); + + it('defaults hideEmpty to true for non-option groups', () => { + const dataSource = createMockDataSource([ + { + id: 'checkbox', + type: checkboxPropertyModelConfig.type, + }, + ]); + + const next = resolveKanbanGroupBy(asDataSource(dataSource)); + expect(next?.columnId).toBe('checkbox'); + expect(next?.hideEmpty).toBe(true); + expect(next?.name).toBe('boolean'); + }); + + it('defaults hideEmpty to false for select grouping', () => { + const dataSource = createMockDataSource([ + { + id: 'select', + type: selectPropertyModelConfig.type, + }, + ]); + + const next = resolveKanbanGroupBy(asDataSource(dataSource)); + expect(next?.columnId).toBe('select'); + expect(next?.hideEmpty).toBe(false); + expect(next?.name).toBe('select'); + }); + + it('preserves sort and explicit hideEmpty when resolving groupBy', () => { + const dataSource = createMockDataSource([ + { + id: 'checkbox', + type: checkboxPropertyModelConfig.type, + }, + ]); + const current: GroupBy = { + type: 'groupBy', + columnId: 'checkbox', + name: 'boolean', + sort: { desc: true }, + hideEmpty: true, + }; + + const next = resolveKanbanGroupBy(asDataSource(dataSource), current); + + expect(next?.columnId).toBe('checkbox'); + expect(next?.sort).toEqual({ desc: true }); + expect(next?.hideEmpty).toBe(true); + }); + + it('replaces current non-groupable column with a valid kanban column', () => { + const dataSource = createMockDataSource([ + { id: 'text', type: textPropertyModelConfig.type }, + { id: 'checkbox', type: checkboxPropertyModelConfig.type }, + ]); + + const next = resolveKanbanGroupBy(asDataSource(dataSource), { + type: 'groupBy', + columnId: 'text', + name: 'text', + }); + + expect(next?.columnId).toBe('checkbox'); + expect(next?.name).toBe('boolean'); + expect(next?.hideEmpty).toBe(true); + }); + }); +}); diff --git a/blocksuite/affine/data-view/src/core/group-by/define.ts b/blocksuite/affine/data-view/src/core/group-by/define.ts index 3baf463637..76fa2e663a 100644 --- a/blocksuite/affine/data-view/src/core/group-by/define.ts +++ b/blocksuite/affine/data-view/src/core/group-by/define.ts @@ -247,12 +247,13 @@ export const groupByMatchers: GroupByConfig[] = [ matchType: t.boolean.instance(), groupName: (_t, v) => `${v?.toString() ?? ''}`, defaultKeys: _t => [ - ungroups, { key: 'true', value: true }, { key: 'false', value: false }, ], valuesGroup: (v, _t) => - typeof v !== 'boolean' ? [ungroups] : [{ key: v.toString(), value: v }], + typeof v !== 'boolean' + ? [{ key: 'false', value: false }] + : [{ key: v.toString(), value: v }], addToGroup: (v: boolean | null, _old: boolean | null) => v, view: createUniComponentFromWebComponent(BooleanGroupView), }), diff --git a/blocksuite/affine/data-view/src/core/group-by/setting.ts b/blocksuite/affine/data-view/src/core/group-by/setting.ts index cfcda11f3a..e6615b4e64 100644 --- a/blocksuite/affine/data-view/src/core/group-by/setting.ts +++ b/blocksuite/affine/data-view/src/core/group-by/setting.ts @@ -17,6 +17,7 @@ import { css, html, unsafeCSS } from 'lit'; import { property, query } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; +import { canGroupable } from '../../view-presets/kanban/group-by-utils.js'; import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js'; import { TableSingleView } from '../../view-presets/table/table-view-manager.js'; import { dataViewCssVariable } from '../common/css-variable.js'; @@ -278,6 +279,9 @@ export const selectGroupByProperty = ( if (property.type$.value === 'title') { return false; } + if (view instanceof KanbanSingleView) { + return canGroupable(view.manager.dataSource, property.id); + } const dataType = property.dataType$.value; if (!dataType) { return false; diff --git a/blocksuite/affine/data-view/src/core/property/types.ts b/blocksuite/affine/data-view/src/core/property/types.ts index 3c4599c125..0f22bc2052 100644 --- a/blocksuite/affine/data-view/src/core/property/types.ts +++ b/blocksuite/affine/data-view/src/core/property/types.ts @@ -16,6 +16,10 @@ export type GetJsonValueFromConfig = export type PropertyConfig = { name: string; hide?: boolean; + kanbanGroup?: { + enabled: boolean; + mutable?: boolean; + }; propertyData: { schema: ZodType; default: () => Data; diff --git a/blocksuite/affine/data-view/src/property-presets/checkbox/define.ts b/blocksuite/affine/data-view/src/property-presets/checkbox/define.ts index 86bc6044b4..bf06210475 100644 --- a/blocksuite/affine/data-view/src/property-presets/checkbox/define.ts +++ b/blocksuite/affine/data-view/src/property-presets/checkbox/define.ts @@ -21,6 +21,10 @@ const FALSE_VALUES = new Set([ export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({ name: 'Checkbox', + kanbanGroup: { + enabled: true, + mutable: true, + }, propertyData: { schema: zod.object({}), default: () => ({}), diff --git a/blocksuite/affine/data-view/src/property-presets/multi-select/define.ts b/blocksuite/affine/data-view/src/property-presets/multi-select/define.ts index ba6788df7a..5fa7d5677f 100644 --- a/blocksuite/affine/data-view/src/property-presets/multi-select/define.ts +++ b/blocksuite/affine/data-view/src/property-presets/multi-select/define.ts @@ -10,6 +10,10 @@ export const multiSelectPropertyType = propertyType('multi-select'); export const multiSelectPropertyModelConfig = multiSelectPropertyType.modelConfig({ name: 'Multi-select', + kanbanGroup: { + enabled: true, + mutable: true, + }, propertyData: { schema: SelectPropertySchema, default: () => ({ diff --git a/blocksuite/affine/data-view/src/property-presets/select/define.ts b/blocksuite/affine/data-view/src/property-presets/select/define.ts index b86d604672..ef579956ed 100644 --- a/blocksuite/affine/data-view/src/property-presets/select/define.ts +++ b/blocksuite/affine/data-view/src/property-presets/select/define.ts @@ -11,6 +11,10 @@ export const SelectPropertySchema = zod.object({ export type SelectPropertyData = zod.infer; export const selectPropertyModelConfig = selectPropertyType.modelConfig({ name: 'Select', + kanbanGroup: { + enabled: true, + mutable: true, + }, propertyData: { schema: SelectPropertySchema, default: () => ({ diff --git a/blocksuite/affine/data-view/src/view-presets/convert.ts b/blocksuite/affine/data-view/src/view-presets/convert.ts index 19e57fa210..21498a397d 100644 --- a/blocksuite/affine/data-view/src/view-presets/convert.ts +++ b/blocksuite/affine/data-view/src/view-presets/convert.ts @@ -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, diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/define.ts b/blocksuite/affine/data-view/src/view-presets/kanban/define.ts index 7a35e93335..f9d1244c49 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/define.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/define.ts @@ -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({ 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({ op: 'and', conditions: [], }, - groupBy: defaultGroupBy(viewManager.dataSource, meta, columnId, data), + groupBy, header: { titleColumn: viewManager.dataSource.properties$.value.find( id => viewManager.dataSource.propertyTypeGet(id) === 'title' diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/group-by-utils.ts b/blocksuite/affine/data-view/src/view-presets/kanban/group-by-utils.ts new file mode 100644 index 0000000000..3f8a7c0afb --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/group-by-utils.ts @@ -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), + }; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts b/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts index 105f8977d1..d7cd704a1c 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts @@ -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 { propertiesRaw$ = computed(() => { @@ -61,16 +106,27 @@ export class KanbanSingleView extends SingleViewBase { ); 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 { 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 { 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]; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/drag.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/drag.ts index b9508205e0..8161231c56 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/drag.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/drag.ts @@ -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(); diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/header.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/header.ts index 2a8667742d..be5344e94d 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/pc/header.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/header.ts @@ -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, diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/created-by/define.ts b/packages/frontend/core/src/blocksuite/database-block/properties/created-by/define.ts index 97404c93f6..4334fac198 100644 --- a/packages/frontend/core/src/blocksuite/database-block/properties/created-by/define.ts +++ b/packages/frontend/core/src/blocksuite/database-block/properties/created-by/define.ts @@ -12,6 +12,10 @@ import zod from 'zod'; export const createdByColumnType = propertyType('created-by'); export const createdByPropertyModelConfig = createdByColumnType.modelConfig({ name: 'Created By', + kanbanGroup: { + enabled: true, + mutable: false, + }, propertyData: { schema: zod.object({}), default: () => ({}), diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/member/define.ts b/packages/frontend/core/src/blocksuite/database-block/properties/member/define.ts index 8f855ce1b2..cf0073e1cb 100644 --- a/packages/frontend/core/src/blocksuite/database-block/properties/member/define.ts +++ b/packages/frontend/core/src/blocksuite/database-block/properties/member/define.ts @@ -24,6 +24,10 @@ export type MemberCellJsonValueType = zod.TypeOf< >; export const memberPropertyModelConfig = memberColumnType.modelConfig({ name: 'Member', + kanbanGroup: { + enabled: true, + mutable: true, + }, propertyData: { schema: zod.object({}), default: () => ({}), diff --git a/tests/blocksuite/e2e/database/selection.spec.ts b/tests/blocksuite/e2e/database/selection.spec.ts index 8cee95b7a8..1f97827f6f 100644 --- a/tests/blocksuite/e2e/database/selection.spec.ts +++ b/tests/blocksuite/e2e/database/selection.spec.ts @@ -338,8 +338,8 @@ test.describe('kanban view selection', () => { rows: ['row1'], columns: [ { - type: 'number', - value: [1], + type: 'checkbox', + value: [true], }, { type: 'rich-text', @@ -350,8 +350,6 @@ test.describe('kanban view selection', () => { await focusKanbanCardHeader(page); await assertKanbanCellSelected(page, { - // group by `number` column, `Ungroups` is hidden because it's empty (hideEmpty: true by default) - // so the first visible group is the one with value "1" at groupIndex: 0 groupIndex: 0, cardIndex: 0, cellIndex: 0, @@ -380,9 +378,9 @@ test.describe('kanban view selection', () => { rows: ['row1', 'row2'], columns: [ { - type: 'number', - // Both rows have value 1 to put them in the same group - value: [1, 1], + type: 'checkbox', + // Both rows are checked so they stay in the same group. + value: [true, true], }, { type: 'rich-text', @@ -394,8 +392,6 @@ test.describe('kanban view selection', () => { await focusKanbanCardHeader(page); await pressArrowUp(page); await assertKanbanCellSelected(page, { - // `Ungroups` is hidden because it's empty (hideEmpty: true by default) - // so the first visible group is "1" at groupIndex: 0 groupIndex: 0, cardIndex: 1, cellIndex: 2, @@ -414,18 +410,18 @@ test.describe('kanban view selection', () => { }) => { await enterPlaygroundRoom(page); await initKanbanViewState(page, { - rows: ['row1', 'row2', 'row3'], + rows: ['row1', 'row2'], columns: [ { - type: 'number', - value: [undefined, 1, 10], + type: 'checkbox', + value: [true, false], }, ], }); await focusKanbanCardHeader(page); - await pressArrowRight(page, 3); + await pressArrowRight(page, 2); await assertKanbanCellSelected(page, { groupIndex: 0, cardIndex: 0, @@ -434,7 +430,7 @@ test.describe('kanban view selection', () => { await pressArrowLeft(page); await assertKanbanCellSelected(page, { - groupIndex: 2, + groupIndex: 1, cardIndex: 0, cellIndex: 0, }); @@ -480,11 +476,11 @@ test.describe('kanban view selection', () => { }) => { await enterPlaygroundRoom(page); await initKanbanViewState(page, { - rows: ['row1', 'row2', 'row3'], + rows: ['row1', 'row2'], columns: [ { - type: 'number', - value: [undefined, 1, 10], + type: 'checkbox', + value: [true, false], }, ], }); @@ -493,7 +489,7 @@ test.describe('kanban view selection', () => { await pressEscape(page); await pressEscape(page); - await pressArrowRight(page, 3); + await pressArrowRight(page, 2); await assertKanbanCardSelected(page, { groupIndex: 0, cardIndex: 0, @@ -501,7 +497,7 @@ test.describe('kanban view selection', () => { await pressArrowLeft(page); await assertKanbanCardSelected(page, { - groupIndex: 2, + groupIndex: 1, cardIndex: 0, }); }); @@ -512,8 +508,8 @@ test.describe('kanban view selection', () => { rows: ['row1', 'row2'], columns: [ { - type: 'number', - value: [undefined, 1], + type: 'checkbox', + value: [true, false], }, ], });