fix(editor): restore grouped manual ordering for kanban and arrange (#14630)

Fixes #14531, where mannual vertical order is broken [Root
Cause](https://github.com/toeverything/AFFiNE/issues/14531#issuecomment-4052422436)

- Restored manual row/card sorting when building grouped kanban data.
- Reapplied `sortRow(...)` to each group before rendering `group.rows`.
- Fixed group/board arrange to reorder from the full group list,
including hidden or empty groups.
- Preserved consistent ordering between the settings panel and persisted
`groupProperties`.

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

* **Refactor**
* Consolidated internal grouping and sorting logic to ensure consistent
ordering across grouped views; visible behavior unchanged.
* Moving groups or cards now uses a single, consistent ordering approach
to avoid intermittent ordering differences.
* **Tests**
* Added tests to verify manual per-group card order is applied and
preserved when moving cards between groups.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
Jasper Zeng
2026-04-06 14:09:05 -04:00
committed by GitHub
parent e3391c0577
commit f41bc2d5c3
2 changed files with 284 additions and 28 deletions
@@ -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<Row['index$']['value']>(undefined),
prev$: signal<Row | undefined>(undefined),
next$: signal<Row | undefined>(undefined),
delete: vi.fn(),
move: vi.fn(),
});
const createGroupTraitHarness = (options?: {
groupProperties?: Array<{
key: string;
hide: boolean;
manuallyCardSort: string[];
}>;
rowIds?: string[];
values?: Record<string, boolean>;
}) => {
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<typeof signal<boolean | undefined>>;
jsonValueSet: ReturnType<typeof vi.fn<(value: unknown) => void>>;
valueSet: ReturnType<typeof vi.fn<(value: unknown) => 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<GroupBy | undefined>({
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(