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 7fa752caca..11ed6fc507 100644 --- a/blocksuite/affine/data-view/src/core/group-by/trait.ts +++ b/blocksuite/affine/data-view/src/core/group-by/trait.ts @@ -112,7 +112,10 @@ export class GroupTrait { return; } const { staticMap, groupInfo } = staticInfo; - const groupMap: Record = { ...staticMap }; + const groupMap: Record = {}; + 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; diff --git a/blocksuite/affine/data-view/src/core/view-manager/single-view.ts b/blocksuite/affine/data-view/src/core/view-manager/single-view.ts index 6777c5dc5a..0678344dd3 100644 --- a/blocksuite/affine/data-view/src/core/view-manager/single-view.ts +++ b/blocksuite/affine/data-view/src/core/view-manager/single-view.ts @@ -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); } diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts index b52ffa2ef3..0e349cde13 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts @@ -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(); }, }), ], diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/kanban-view-ui-logic.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/kanban-view-ui-logic.ts index 98156c700a..faa70c0bfd 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/kanban-view-ui-logic.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/kanban-view-ui-logic.ts @@ -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 = () => {}; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts index 7cd4c512ae..660fce99ff 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts @@ -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(); }, }), ], diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/selection.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/selection.ts index 8caaaa6227..e4cde95c61 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/selection.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/selection.ts @@ -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(); } } diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts index bda7d61b83..a88d8950fa 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts @@ -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(); }, }), ]); diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/kanban-view-ui-logic.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/kanban-view-ui-logic.ts index a8c715f2bc..b4c8c6fac8 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/pc/kanban-view-ui-logic.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/kanban-view-ui-logic.ts @@ -73,6 +73,7 @@ export class KanbanViewUILogic extends DataViewUILogicBase< rowId, }); } + this.ui$.value?.requestUpdate(); return rowId; }; diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts index 1d292ca760..47569eba6c 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts @@ -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(); }, }), ]); diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/menu.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/menu.ts index 514a7c20cb..151d837e69 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/mobile/menu.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/menu.ts @@ -38,6 +38,7 @@ export const popMobileRowMenu = ( prefix: DeleteIcon(), select: () => { view.rowsDelete([rowId]); + tableViewLogic.ui$.value?.requestUpdate(); }, }), ], diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/clipboard.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/clipboard.ts index 8cc28bea29..6b016bcd62 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/clipboard.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/clipboard.ts @@ -44,6 +44,7 @@ export class TableClipboardController implements ReactiveController { } if (deleteRows.length) { this.logic.view.rowsDelete(deleteRows); + this.logic.ui$.value?.requestUpdate(); } } this.clipboard diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts index f3c1371f73..951969199b 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts @@ -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 { diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/selection.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/selection.ts index a5bb364a0d..3b0df94cda 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/selection.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/selection.ts @@ -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() { diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.ts index 7d5158c161..457c1d7c83 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.ts @@ -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) diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.ts index 39626e2fc0..29411f9f23 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.ts @@ -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(); }, }), ]); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/menu.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/menu.ts index 15dd637ad2..a467119e3d 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/menu.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/menu.ts @@ -71,6 +71,7 @@ export const popRowMenu = ( prefix: DeleteIcon(), select: () => { selectionController.view.rowsDelete(rows); + selectionController.logic.ui$.value?.requestUpdate(); }, }), ], diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/clipboard.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/clipboard.ts index d17a874203..869a4bb02c 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/clipboard.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/clipboard.ts @@ -43,6 +43,7 @@ export class TableClipboardController implements ReactiveController { } if (deleteRows.length) { this.logic.view.rowsDelete(deleteRows); + this.logic.ui$.value?.requestUpdate(); } } this.clipboard diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts index 9191833059..702e7dfc3c 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts @@ -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 { diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/selection.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/selection.ts index bf6b2cf317..f542938673 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/selection.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/selection.ts @@ -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() { diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts index 62d2493d4f..460736b0f4 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts @@ -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(); }, }), ]); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/menu.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/menu.ts index 6a02c2f2d1..844f1b5c85 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/menu.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/menu.ts @@ -71,6 +71,7 @@ export const popRowMenu = ( prefix: DeleteIcon(), select: () => { selectionController.view.rowsDelete(rows); + selectionController.logic.ui$.value?.requestUpdate(); }, }), ], diff --git a/packages/frontend/core/src/__tests__/data-view-actions.spec.ts b/packages/frontend/core/src/__tests__/data-view-actions.spec.ts new file mode 100644 index 0000000000..ca1c29bbb5 --- /dev/null +++ b/packages/frontend/core/src/__tests__/data-view-actions.spec.ts @@ -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(); + }); +});