diff --git a/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts b/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts index c43a4d92e..ab12f85c0 100644 --- a/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts +++ b/blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts @@ -6,6 +6,7 @@ import type { DataSource } from '../core/data-source/base.js'; import { DetailSelection } from '../core/detail/selection.js'; import type { FilterGroup } from '../core/filter/types.js'; import { groupByMatchers } from '../core/group-by/define.js'; +import { GroupTrait, sortByManually } from '../core/group-by/trait.js'; import { t } from '../core/logical/type-presets.js'; import type { DataViewCellLifeCycle } from '../core/property/index.js'; import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js'; @@ -25,6 +26,7 @@ import { 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'; +import type { Row } from '../core/view-manager/row.js'; type Column = { id: string; @@ -214,7 +216,205 @@ const createDragController = () => { return new KanbanDragController({} as DragLogic); }; +const createTestRow = (rowId: string): Row => ({ + rowId, + cells$: signal([]) as Row['cells$'], + index$: signal(undefined), + prev$: signal(undefined), + next$: signal(undefined), + delete: vi.fn(), + move: vi.fn(), +}); + +const createGroupTraitHarness = (options?: { + groupProperties?: Array<{ + key: string; + hide: boolean; + manuallyCardSort: string[]; + }>; + rowIds?: string[]; + values?: Record; +}) => { + const dataSource = createMockDataSource([ + { + id: 'checkbox', + type: checkboxPropertyModelConfig.type, + }, + ]); + + const property = { + id: 'checkbox', + dataType$: signal(t.boolean.instance()), + meta$: signal({ config: {} }), + }; + + const groupProperties = options?.groupProperties ?? [ + { + key: 'true', + hide: false, + manuallyCardSort: [], + }, + { + key: 'false', + hide: false, + manuallyCardSort: [], + }, + ]; + const rows = options?.rowIds ?? []; + const data$ = signal({ + groupProperties, + }); + const cellValues = new Map( + Object.entries(options?.values ?? {}).map(([rowId, value]) => [ + `${rowId}:checkbox`, + value, + ]) + ); + const cells = new Map< + string, + { + jsonValue$: ReturnType>; + jsonValueSet: ReturnType void>>; + valueSet: ReturnType void>>; + } + >(); + + const cellGetOrCreate = (rowId: string, propertyId: string) => { + const key = `${rowId}:${propertyId}`; + const existing = cells.get(key); + if (existing) { + return existing; + } + + const jsonValue$ = signal(cellValues.get(key)); + const update = (value: unknown) => { + jsonValue$.value = value as boolean | undefined; + cellValues.set(key, value as boolean); + }; + const cell = { + jsonValue$, + jsonValueSet: vi.fn(update), + valueSet: vi.fn(update), + }; + cells.set(key, cell); + return cell; + }; + + const view = { + data$, + rows$: signal(rows.map(createTestRow)), + isLocked$: signal(false), + manager: { + dataSource: asDataSource(dataSource), + }, + propertyGetOrCreate: () => property, + cellGetOrCreate, + }; + + const groupBy$ = signal({ + type: 'groupBy', + columnId: 'checkbox', + name: 'boolean', + hideEmpty: false, + sort: { desc: false }, + }); + + const ops = { + groupBySet: vi.fn(), + sortGroup: (keys: string[], asc?: boolean) => { + const sorted = sortByManually( + keys, + value => value, + data$.value.groupProperties.map(value => value.key) + ); + return asc === false ? sorted.reverse() : sorted; + }, + sortRow: (groupKey: string, groupedRows: Row[]) => { + const group = data$.value.groupProperties.find( + value => value.key === groupKey + ); + return sortByManually( + groupedRows, + row => row.rowId, + group?.manuallyCardSort ?? [] + ); + }, + changeGroupSort: vi.fn(), + changeRowSort: vi.fn(), + changeGroupHide: vi.fn(), + }; + + return { + groupTrait: new GroupTrait(groupBy$, view as never, ops), + ops, + cells, + }; +}; + describe('kanban', () => { + describe('group trait', () => { + it('reapplies manual card order when building grouped rows', () => { + const { groupTrait } = createGroupTraitHarness({ + groupProperties: [ + { + key: 'true', + hide: false, + manuallyCardSort: ['row-2', 'row-1'], + }, + { + key: 'false', + hide: false, + manuallyCardSort: [], + }, + ], + rowIds: ['row-1', 'row-2'], + values: { + 'row-1': true, + 'row-2': true, + }, + }); + + expect( + groupTrait.groupsDataList$.value + ?.find(group => group.key === 'true') + ?.rows.map(row => row.rowId) + ).toEqual(['row-2', 'row-1']); + }); + + it('preserves manual group order when updating card sort', () => { + const { groupTrait, ops, cells } = createGroupTraitHarness({ + groupProperties: [ + { + key: 'false', + hide: false, + manuallyCardSort: ['row-1'], + }, + { + key: 'true', + hide: false, + manuallyCardSort: ['row-2'], + }, + ], + rowIds: ['row-1', 'row-2'], + values: { + 'row-1': false, + 'row-2': true, + }, + }); + + groupTrait.moveCardTo('row-1', 'false', 'true', 'end'); + + expect(ops.changeRowSort).toHaveBeenCalledWith( + ['false', 'true'], + 'true', + ['row-2', 'row-1'] + ); + expect(cells.get('row-1:checkbox')?.jsonValueSet).toHaveBeenCalledWith( + true + ); + }); + }); + describe('group-by define', () => { it('boolean group should not include ungroup bucket', () => { const booleanGroup = groupByMatchers.find( diff --git a/blocksuite/affine/data-view/src/core/group-by/trait.ts b/blocksuite/affine/data-view/src/core/group-by/trait.ts index 14b8055bc..18a30ef09 100644 --- a/blocksuite/affine/data-view/src/core/group-by/trait.ts +++ b/blocksuite/affine/data-view/src/core/group-by/trait.ts @@ -19,7 +19,6 @@ import type { SingleView } from '../view-manager/single-view.js'; import { compareDateKeys } from './compare-date-keys.js'; import { defaultGroupBy } from './default.js'; import { findGroupByConfigByName, getGroupByService } from './matcher.js'; -// Test import type { GroupByConfig } from './types.js'; export type GroupInfo< @@ -88,6 +87,60 @@ function hasGroupProperties( return value === undefined || Array.isArray(value); } +const getOrderedGroupKeys = ( + keys: string[], + groupInfo: GroupInfo | undefined, + sortGroup: (keys: string[], asc?: boolean) => string[], + sortAsc: boolean +) => { + if (groupInfo?.config.matchType.name === 'Date') { + return [...keys].sort(compareDateKeys(groupInfo.config.name, sortAsc)); + } + return sortGroup(keys, sortAsc); +}; + +const applyGroupRowSort = ( + groups: Record, + orderedKeys: string[], + sortRow: (groupKey: string, rows: Row[]) => Row[] +) => { + orderedKeys.forEach(key => { + const group = groups[key]; + if (!group) { + return; + } + group.rows = sortRow(key, group.rows); + }); +}; + +const reorderGroupKeys = ( + keys: string[], + groupKey: string, + position: InsertToPosition +) => { + const currentIndex = keys.findIndex(key => key === groupKey); + if (currentIndex < 0) { + return keys; + } + if (typeof position === 'object') { + if (position.id === groupKey) { + return keys; + } + if (!keys.includes(position.id)) { + return keys; + } + } + + const reordered = [...keys]; + reordered.splice(currentIndex, 1); + const index = insertPositionToIndex(position, reordered, key => key); + if (index < 0) { + return keys; + } + reordered.splice(index, 0, groupKey); + return reordered; +}; + export class GroupTrait { hideEmpty$ = signal(true); sortAsc$ = signal(true); @@ -202,15 +255,13 @@ export class GroupTrait { if (!map) return; const gi = this.groupInfo$.value; - let ordered: string[]; - - if (gi?.config.matchType.name === 'Date') { - ordered = Object.keys(map).sort( - compareDateKeys(gi.config.name, this.sortAsc$.value) - ); - } else { - ordered = this.ops.sortGroup(Object.keys(map), this.sortAsc$.value); - } + const ordered = getOrderedGroupKeys( + Object.keys(map), + gi, + this.ops.sortGroup, + this.sortAsc$.value + ); + applyGroupRowSort(map, ordered, this.ops.sortRow); return ordered .map(k => map[k]) @@ -233,14 +284,13 @@ export class GroupTrait { const info = this.groupInfo$.value; if (!map || !info) return; - let orderedKeys: string[]; - if (info.config.matchType.name === 'Date') { - orderedKeys = Object.keys(map).sort( - compareDateKeys(info.config.name, this.sortAsc$.value) - ); - } else { - orderedKeys = this.ops.sortGroup(Object.keys(map), this.sortAsc$.value); - } + const orderedKeys = getOrderedGroupKeys( + Object.keys(map), + info, + this.ops.sortGroup, + this.sortAsc$.value + ); + applyGroupRowSort(map, orderedKeys, this.ops.sortRow); const visible: Group[] = []; const hidden: Group[] = []; @@ -430,23 +480,29 @@ export class GroupTrait { .map(row => row.rowId) ?? []; const index = insertPositionToIndex(position, rows, row => row); rows.splice(index, 0, rowId); - const groupKeys = Object.keys(groupMap); + const groupKeys = getOrderedGroupKeys( + Object.keys(groupMap), + this.groupInfo$.value, + this.ops.sortGroup, + this.sortAsc$.value + ); this.ops.changeRowSort(groupKeys, toGroupKey, rows); } moveGroupTo(groupKey: string, position: InsertToPosition) { - const groups = this.groupsDataList$.value; + const groups = this.groupsDataListAll$.value; if (!groups) { return; } - const keys = groups.map(v => v!.key); - keys.splice( - keys.findIndex(key => key === groupKey), - 1 - ); - const index = insertPositionToIndex(position, keys, key => key); - keys.splice(index, 0, groupKey); - this.changeGroupSort(keys); + const currentKeys = groups.map(group => group.key); + const reorderedKeys = reorderGroupKeys(currentKeys, groupKey, position); + if ( + currentKeys.length === reorderedKeys.length && + currentKeys.every((key, index) => key === reorderedKeys[index]) + ) { + return; + } + this.changeGroupSort(reorderedKeys); } removeFromGroup(rowId: string, key: string) {