fix(editor): show added or deleted rows immediately in grouped table and Kanban views (#12731)

https://github.com/user-attachments/assets/214fbe4f-b667-44b7-85a3-77ef4cfa8cca

This PR fixes a bug where adding or deleting rows in a grouped table
view did not visually update the UI until the user manually refreshed
the page or navigated away and back. The issue gave the impression that
the action had not completed.

Same issue for Kanban cards.

The result now is:
Users now see new rows or deleted rows reflected in real-time without
needing to reload or navigate away. This applies to both grouped table
views and Kanban cards.

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

- **Bug Fixes**
- Ensured the UI updates immediately after adding, deleting, or moving
cards and rows in Kanban and Table views on both mobile and desktop.
- Fixed issues where UI changes were not reflected after certain
actions, such as ungrouping, deleting, or inserting items.
- Improved row locking behavior during add and delete operations to
prevent UI inconsistencies.
- **Tests**
- Added comprehensive tests for row operations and menu interactions to
verify UI updates and correct method calls in data views.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: zzj3720 <zuozijian1994@gmail.com>
This commit is contained in:
Richard Lora
2025-06-12 04:02:37 -04:00
committed by GitHub
parent d2664480f7
commit 8ca17864f1
22 changed files with 174 additions and 2 deletions
@@ -112,7 +112,10 @@ export class GroupTrait {
return;
}
const { staticMap, groupInfo } = staticInfo;
const groupMap: Record<string, Group> = { ...staticMap };
const groupMap: Record<string, Group> = {};
Object.entries(staticMap).forEach(([key, group]) => {
groupMap[key] = new Group(key, group.value, groupInfo, this);
});
this.view.rows$.value.forEach(row => {
const value = this.view.cellGetOrCreate(row.rowId, groupInfo.property.id)
.jsonValue$.value;
@@ -182,6 +185,7 @@ export class GroupTrait {
) {}
addToGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
const groupInfo = this.groupInfo$.value;
if (!groupMap || !groupInfo) {
@@ -254,6 +258,7 @@ export class GroupTrait {
toGroupKey: string,
position: InsertToPosition
) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
@@ -290,6 +295,7 @@ export class GroupTrait {
}
moveGroupTo(groupKey: string, position: InsertToPosition) {
this.view.lockRows(false);
const groups = this.groupsDataList$.value;
if (!groups) {
return;
@@ -305,6 +311,7 @@ export class GroupTrait {
}
removeFromGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
@@ -323,6 +330,7 @@ export class GroupTrait {
}
updateValue(rows: string[], value: unknown) {
this.view.lockRows(false);
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;
@@ -128,6 +128,7 @@ export abstract class SingleViewBase<
);
rowsDelete(rows: string[]): void {
this.lockRows(false);
this.dataSource.rowDelete(rows);
}
@@ -258,6 +259,7 @@ export abstract class SingleViewBase<
abstract propertyGetOrCreate(propertyId: string): Property;
rowAdd(insertPosition: InsertToPosition | number): string {
this.lockRows(false);
return this.dataSource.rowAdd(insertPosition);
}
@@ -61,10 +61,12 @@ export class MobileKanbanGroup extends SignalWatcher(
private readonly clickAddCard = () => {
this.view.addCard('end', this.group.key);
this.requestUpdate();
};
private readonly clickAddCardInStart = () => {
this.view.addCard('start', this.group.key);
this.requestUpdate();
};
private readonly clickGroupOptions = (e: MouseEvent) => {
@@ -79,12 +81,14 @@ export class MobileKanbanGroup extends SignalWatcher(
this.group.rows.forEach(row => {
this.group.manager.removeFromGroup(row.rowId, this.group.key);
});
this.requestUpdate();
},
}),
menu.action({
name: 'Delete Cards',
select: () => {
this.view.rowsDelete(this.group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
],
@@ -66,7 +66,9 @@ export class MobileKanbanViewUILogic extends DataViewUILogicBase<
addRow = (position: InsertToPosition) => {
if (this.readonly) return;
return this.view.rowAdd(position);
const id = this.view.rowAdd(position);
this.ui$.value?.requestUpdate();
return id;
};
focusFirstCell = () => {};
@@ -83,6 +83,7 @@ export const popCardMenu = (
{ before: true, id: cardId },
groupKey
);
kanbanViewLogic.ui$.value?.requestUpdate();
},
}),
menu.action({
@@ -97,6 +98,7 @@ export const popCardMenu = (
{ before: false, id: cardId },
groupKey
);
kanbanViewLogic.ui$.value?.requestUpdate();
},
}),
],
@@ -111,6 +113,7 @@ export const popCardMenu = (
prefix: DeleteIcon(),
select: () => {
kanbanViewLogic.view.rowsDelete([cardId]);
kanbanViewLogic.ui$.value?.requestUpdate();
},
}),
],
@@ -128,6 +128,7 @@ export class KanbanSelectionController implements ReactiveController {
if (selection.selectionType === 'card') {
this.view.rowsDelete(selection.cards.map(v => v.cardId));
this.selection = undefined;
this.logic.ui$.value?.requestUpdate();
}
}
@@ -110,6 +110,7 @@ export class KanbanGroup extends SignalWatcher(
isEditing: true,
};
});
this.requestUpdate();
};
private readonly clickAddCardInStart = () => {
@@ -127,6 +128,7 @@ export class KanbanGroup extends SignalWatcher(
isEditing: true,
};
});
this.requestUpdate();
};
private readonly clickGroupOptions = (e: MouseEvent) => {
@@ -139,12 +141,14 @@ export class KanbanGroup extends SignalWatcher(
this.group.rows.forEach(row => {
this.group.manager.removeFromGroup(row.rowId, this.group.key);
});
this.requestUpdate();
},
}),
menu.action({
name: 'Delete Cards',
select: () => {
this.view.rowsDelete(this.group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
]);
@@ -73,6 +73,7 @@ export class KanbanViewUILogic extends DataViewUILogicBase<
rowId,
});
}
this.ui$.value?.requestUpdate();
return rowId;
};
@@ -51,10 +51,12 @@ export class MobileTableGroup extends SignalWatcher(
private readonly clickAddRow = () => {
this.view.rowAdd('end', this.group?.key);
this.requestUpdate();
};
private readonly clickAddRowInStart = () => {
this.view.rowAdd('start', this.group?.key);
this.requestUpdate();
};
private readonly clickGroupOptions = (e: MouseEvent) => {
@@ -77,6 +79,7 @@ export class MobileTableGroup extends SignalWatcher(
name: 'Delete Cards',
select: () => {
this.view.rowsDelete(group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
]);
@@ -38,6 +38,7 @@ export const popMobileRowMenu = (
prefix: DeleteIcon(),
select: () => {
view.rowsDelete([rowId]);
tableViewLogic.ui$.value?.requestUpdate();
},
}),
],
@@ -44,6 +44,7 @@ export class TableClipboardController implements ReactiveController {
}
if (deleteRows.length) {
this.logic.view.rowsDelete(deleteRows);
this.logic.ui$.value?.requestUpdate();
}
}
this.clipboard
@@ -30,6 +30,7 @@ export class TableHotkeysController implements ReactiveController {
const rows = TableViewRowSelection.rowsIds(selection);
this.selectionController.selection = undefined;
this.logic.view.rowsDelete(rows);
this.logic.ui$.value?.requestUpdate();
return;
}
const {
@@ -376,6 +376,7 @@ export class TableSelectionController implements ReactiveController {
deleteRow(rowId: string) {
this.view.rowsDelete([rowId]);
this.focusToCell('up');
this.logic.ui$.value?.requestUpdate();
}
focusFirstCell() {
@@ -45,6 +45,7 @@ export class TableGroupFooter extends WithDisposable(ShadowlessElement) {
private readonly clickAddRow = () => {
const group = this.group$.value;
const rowId = this.tableViewManager.rowAdd('end', group?.key);
this.requestUpdate();
requestAnimationFrame(() => {
const rowIndex = this.selectionController.getRow(group?.key, rowId)
@@ -58,6 +58,7 @@ export class TableGroupHeader extends SignalWatcher(
return;
}
this.tableViewManager.rowAdd('start', group.key);
this.requestUpdate();
const selectionController = this.selectionController;
selectionController.selection = undefined;
requestAnimationFrame(() => {
@@ -95,6 +96,7 @@ export class TableGroupHeader extends SignalWatcher(
name: 'Delete Cards',
select: () => {
this.tableViewManager.rowsDelete(group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
]);
@@ -71,6 +71,7 @@ export const popRowMenu = (
prefix: DeleteIcon(),
select: () => {
selectionController.view.rowsDelete(rows);
selectionController.logic.ui$.value?.requestUpdate();
},
}),
],
@@ -43,6 +43,7 @@ export class TableClipboardController implements ReactiveController {
}
if (deleteRows.length) {
this.logic.view.rowsDelete(deleteRows);
this.logic.ui$.value?.requestUpdate();
}
}
this.clipboard
@@ -28,6 +28,7 @@ export class TableHotkeysController implements ReactiveController {
const rows = TableViewRowSelection.rowsIds(selection);
this.selectionController.selection = undefined;
this.logic.view.rowsDelete(rows);
this.logic.ui$.value?.requestUpdate();
return;
}
const {
@@ -351,6 +351,7 @@ export class TableSelectionController implements ReactiveController {
deleteRow(rowId: string) {
this.view.rowsDelete([rowId]);
this.focusToCell('up');
this.logic.ui$.value?.requestUpdate();
}
focusFirstCell() {
@@ -83,6 +83,7 @@ export class TableGroup extends SignalWatcher(
},
isEditing: true,
});
this.requestUpdate();
});
};
@@ -102,6 +103,7 @@ export class TableGroup extends SignalWatcher(
},
isEditing: true,
});
this.requestUpdate();
});
};
@@ -125,6 +127,7 @@ export class TableGroup extends SignalWatcher(
name: 'Delete Cards',
select: () => {
this.view.rowsDelete(group.rows.map(row => row.rowId));
this.requestUpdate();
},
}),
]);
@@ -71,6 +71,7 @@ export const popRowMenu = (
prefix: DeleteIcon(),
select: () => {
selectionController.view.rowsDelete(rows);
selectionController.logic.ui$.value?.requestUpdate();
},
}),
],
@@ -0,0 +1,129 @@
/* eslint-disable rxjs/finnish */
import { computed, signal } from '@preact/signals-core';
import { describe, expect, test, vi } from 'vitest';
// mock context-menu utilities
const popFilterableSimpleMenu = vi.fn();
vi.mock('@blocksuite/affine-components/context-menu', () => ({
menu: {
action: (opts: any) => opts,
group: (opts: any) => opts,
subMenu: (opts: any) => opts,
},
// avoid early access during module mocking
popFilterableSimpleMenu: (...args: any[]) => popFilterableSimpleMenu(...args),
popupTargetFromElement: (el: any) => el,
}));
import { SingleViewBase } from '../../../../../blocksuite/affine/data-view/src/core/view-manager/single-view.js';
import { MobileKanbanViewUILogic } from '../../../../../blocksuite/affine/data-view/src/view-presets/kanban/mobile/kanban-view-ui-logic.js';
import { popCardMenu } from '../../../../../blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.js';
import { popMobileRowMenu } from '../../../../../blocksuite/affine/data-view/src/view-presets/table/mobile/menu.js';
class TestView extends SingleViewBase {
detailProperties$ = computed(() => []);
mainProperties$ = computed(() => ({}));
properties$ = computed(() => []);
propertiesRaw$ = computed(() => []);
readonly$ = computed(() => false);
get type() {
return 'test';
}
isShow() {
return true;
}
propertyGetOrCreate() {
return {} as any;
}
}
describe('data view helpers', () => {
test('rowAdd and rowsDelete unlock the view', () => {
const ds = {
rowAdd: vi.fn().mockReturnValue('id'),
rowDelete: vi.fn(),
} as any;
const manager = { dataSource: ds } as any;
const view = new TestView(manager, 'v1');
view.lockRows(true);
const id = view.rowAdd('end');
expect(id).toBe('id');
expect(ds.rowAdd).toHaveBeenCalledWith('end');
expect(view.isLocked).toBe(false);
view.lockRows(true);
view.rowsDelete(['a']);
expect(ds.rowDelete).toHaveBeenCalledWith(['a']);
expect(view.isLocked).toBe(false);
});
test('MobileKanbanViewUILogic.addRow triggers update', () => {
const root = {
setSelection: vi.fn(),
selection$: signal(undefined),
config: {},
} as any;
const view = {
readonly$: signal(false),
rowAdd: vi.fn().mockReturnValue('r1'),
groupTrait: {},
id: 'v',
manager: {},
} as any;
const logic = new MobileKanbanViewUILogic(root, view);
const update = vi.fn();
logic.ui$.value = { requestUpdate: update } as any;
if (!logic.ui$.value) {
throw new Error('UI state must be defined before calling addRow');
}
const id = logic.addRow('end');
expect(id).toBe('r1');
expect(view.rowAdd).toHaveBeenCalledWith('end');
expect(update).toHaveBeenCalled();
});
test('popCardMenu actions request update', () => {
const update = vi.fn();
const kanbanViewLogic = {
view: {
addCard: vi.fn(),
rowsDelete: vi.fn(),
traitGet: () => ({ groupsDataList$: signal([]), moveCardTo: vi.fn() }),
},
ui$: signal({ requestUpdate: update }),
root: { openDetailPanel: vi.fn() },
} as any;
popCardMenu({} as any, 'g', 'c', kanbanViewLogic);
const groups = popFilterableSimpleMenu.mock.calls[0][1] as any;
groups[2].items[0].select();
expect(kanbanViewLogic.view.addCard).toHaveBeenCalledWith(
{ before: true, id: 'c' },
'g'
);
expect(update).toHaveBeenCalledTimes(1);
groups[2].items[1].select();
expect(kanbanViewLogic.view.addCard).toHaveBeenCalledWith(
{ before: false, id: 'c' },
'g'
);
groups[3].items[0].select();
expect(kanbanViewLogic.view.rowsDelete).toHaveBeenCalledWith(['c']);
expect(update).toHaveBeenCalledTimes(3);
});
test('popMobileRowMenu delete action requests update', () => {
const update = vi.fn();
const tableViewLogic = {
ui$: signal({ requestUpdate: update }),
root: { openDetailPanel: vi.fn() },
} as any;
const view = { rowsDelete: vi.fn() } as any;
popMobileRowMenu({} as any, 'r1', tableViewLogic, view);
const groups = popFilterableSimpleMenu.mock.calls.pop()![1] as any;
groups[1].items[0].select();
expect(view.rowsDelete).toHaveBeenCalledWith(['r1']);
expect(update).toHaveBeenCalled();
});
});