Files
AFFiNE-Mirror/blocksuite/affine/data-view/src/view-presets/table/table-view-manager.ts
Pixel Perfect fb9f49b948 fix(data-view): preserve filtering on hidden properties (#14500)
Fixes issue #14036 where hiding a column used in filters caused empty
table/kanban results.

Root cause: filter evaluation built the row map from visible properties
only.

Change: evaluate filters using full property set (propertiesRaw$) so
hidden filtered columns still participate.

Added unit regressions for both table and kanban hidden-column filtering
behavior.

Verified this does fix the filtering issue for hidden columns:

<img width="3440" height="1440" alt="Screenshot of before and after
views of a database with hidden columns and filtering on said column"
src="https://github.com/user-attachments/assets/c1e2674f-06be-44e9-97bd-63593172f05b"
/>

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

* **Bug Fixes**
* Fixed filtering in Kanban and Table views so filters evaluate against
all properties (including hidden/raw columns), ensuring consistent
results regardless of column visibility.
* **Tests**
* Added tests covering filtering behavior with hidden and filtered
columns to prevent regressions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-23 20:45:12 +08:00

492 lines
13 KiB
TypeScript

import {
insertPositionToIndex,
type InsertToPosition,
} from '@blocksuite/affine-shared/utils';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { evalFilter } from '../../core/filter/eval.js';
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
import type { FilterGroup } from '../../core/filter/types.js';
import { emptyFilterGroup } from '../../core/filter/utils.js';
import {
GroupTrait,
groupTraitKey,
sortByManually,
} from '../../core/group-by/trait.js';
import { fromJson } from '../../core/property/utils';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import { type Row, RowBase } from '../../core/view-manager/row.js';
import {
type SingleView,
SingleViewBase,
} from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js';
import type { TableViewData } from './define.js';
export const materializeColumnsByPropertyIds = (
columns: TableColumnData[],
propertyIds: string[],
getDefaultWidth: (id: string) => number = () => DEFAULT_COLUMN_WIDTH
) => {
const needShow = new Set(propertyIds);
const orderedColumns: TableColumnData[] = [];
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, width: getDefaultWidth(id), hide: undefined });
}
return orderedColumns;
};
export const materializeTableColumns = (
columns: TableColumnData[],
propertyIds: string[],
getDefaultWidth?: (id: string) => number
) => {
const nextColumns = materializeColumnsByPropertyIds(
columns,
propertyIds,
getDefaultWidth
);
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 TableSingleView extends SingleViewBase<TableViewData> {
propertiesRaw$ = computed(() => {
const needShow = new Set(this.dataSource.properties$.value);
const result: string[] = [];
this.data$.value?.columns.forEach(v => {
if (needShow.has(v.id)) {
result.push(v.id);
needShow.delete(v.id);
}
});
result.push(...needShow);
return result.map(id => this.propertyGetOrCreate(id));
});
properties$ = computed(() => {
return this.propertiesRaw$.value.filter(property => !property.hide$.value);
});
private readonly filter$ = computed(() => {
return this.data$.value?.filter ?? emptyFilterGroup;
});
private readonly groupBy$ = computed(() => {
return this.data$.value?.groupBy;
});
private readonly sortList$ = computed(() => {
return this.data$.value?.sort;
});
private readonly sortManager = this.traitSet(
sortTraitKey,
new SortManager(this.sortList$, this, {
setSortList: sortList => {
this.dataUpdate(data => {
return {
sort: {
...data.sort,
...sortList,
},
};
});
},
})
);
detailProperties$ = computed(() => {
return this.propertiesRaw$.value.filter(
property => property.type$.value !== 'title'
);
});
filterTrait = this.traitSet(
filterTraitKey,
new FilterTrait(this.filter$, this, {
filterSet: (filter: FilterGroup) => {
this.dataUpdate(() => {
return {
filter,
};
});
},
})
);
groupTrait = this.traitSet(
groupTraitKey,
new GroupTrait(this.groupBy$, this, {
groupBySet: groupBy => {
this.dataUpdate(() => {
return {
groupBy,
};
});
},
sortGroup: (ids, asc) => {
const sorted = sortByManually(
ids,
v => v,
this.groupProperties.map(v => v.key)
);
// If descending order is requested, reverse the sorted array
return asc === false ? sorted.reverse() : sorted;
},
sortRow: (key, rows) => {
const property = this.groupProperties.find(v => v.key === key);
return sortByManually(
rows,
v => v.rowId,
property?.manuallyCardSort ?? []
);
},
changeGroupSort: keys => {
const map = new Map(this.groupProperties.map(v => [v.key, v]));
this.dataUpdate(() => {
return {
groupProperties: keys.map(key => {
const property = map.get(key);
if (property) {
return property;
}
return {
key,
hide: false,
manuallyCardSort: [],
};
}),
};
});
},
changeRowSort: (groupKeys, groupKey, keys) => {
const map = new Map(this.groupProperties.map(v => [v.key, v]));
this.dataUpdate(() => {
return {
groupProperties: groupKeys.map(key => {
if (key === groupKey) {
const group = map.get(key);
return group
? {
...group,
manuallyCardSort: keys,
}
: {
key,
hide: false,
manuallyCardSort: keys,
};
} else {
return (
map.get(key) ?? {
key,
hide: false,
manuallyCardSort: [],
}
);
}
}),
};
});
},
changeGroupHide: (key, hide) => {
this.dataUpdate(() => {
const list = [...this.groupProperties];
const idx = list.findIndex(g => g.key === key);
if (idx >= 0) {
const target = list[idx];
if (!target) {
return { groupProperties: list };
}
list[idx] = { ...target, hide };
} else {
const order = (this.groupTrait.groupsDataListAll$.value ?? [])
.map(g => g?.key)
.filter((k): k is string => !!k);
let insertPos = 0;
for (const k of order) {
if (k === key) break;
if (list.some(g => g.key === k)) insertPos++;
}
list.splice(insertPos, 0, { key, hide, manuallyCardSort: [] });
}
return { groupProperties: list };
});
},
})
);
mainProperties$ = computed(() => {
return (
this.data$.value?.header ?? {
titleColumn: this.propertiesRaw$.value.find(
property => property.type$.value === 'title'
)?.id,
iconColumn: 'type',
}
);
});
readonly$ = computed(() => {
return this.manager.readonly$.value;
});
get groupProperties() {
return this.data$.value?.groupProperties ?? [];
}
get name(): string {
return this.data$.value?.name ?? '';
}
override get type(): string {
return this.data$.value?.mode ?? 'table';
}
isShow(rowId: string): boolean {
if (this.filter$.value?.conditions.length) {
const rowMap = Object.fromEntries(
this.propertiesRaw$.value.map(column => [
column.id,
column.cellGetOrCreate(rowId).jsonValue$.value,
])
);
return evalFilter(this.filter$.value, rowMap);
}
return true;
}
propertyGetOrCreate(columnId: string): TableProperty {
return new TableProperty(this, columnId);
}
override rowGetOrCreate(rowId: string): TableRow {
return new TableRow(this, rowId);
}
override rowAdd(
insertPosition: InsertToPosition | number,
groupKey?: string
): string {
const id = super.rowAdd(insertPosition);
const filter = this.filter$.value;
if (filter.conditions.length > 0) {
const defaultValues = generateDefaultValues(filter, this.vars$.value);
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
const property = this.propertyGetOrCreate(propertyId);
const propertyMeta = property.meta$.value;
if (propertyMeta) {
const value = fromJson(propertyMeta.config, {
value: jsonValue,
data: property.data$.value,
dataSource: this.dataSource,
});
this.cellGetOrCreate(id, propertyId).valueSet(value);
}
});
}
if (groupKey && id) {
this.groupTrait.addToGroup(id, groupKey);
}
return id;
}
override rowsMapping(rows: Row[]) {
return this.sortManager.sort(super.rowsMapping(rows));
}
readonly computedProperties$: ReadonlySignal<TableColumnData[]> = computed(
() => {
return this.propertiesRaw$.value.map(property => {
return {
id: property.id,
hide: property.hide$.value,
width: property.width$.value,
statCalcType: property.statCalcOp$.value,
};
});
}
);
private materializeColumns() {
const data = this.data$.value;
if (!data) {
return;
}
const nextColumns = materializeTableColumns(
data.columns,
this.dataSource.properties$.value,
id => this.propertyGetOrCreate(id).width$.value
);
if (nextColumns === data.columns) {
return;
}
this.dataUpdate(() => ({ columns: nextColumns }));
}
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 table.
queueMicrotask(() => {
this.materializeColumns();
});
}
}
type TableColumnData = TableViewData['columns'][number];
export class TableProperty extends PropertyBase {
override hideSet(hide: boolean): void {
this.viewDataUpdate(data => {
return {
...data,
hide,
};
});
}
override move(position: InsertToPosition): void {
this.tableView.dataUpdate(() => {
const columnIndex = this.tableView.computedProperties$.value.findIndex(
v => v.id === this.id
);
if (columnIndex < 0) {
return {};
}
const columns = [...this.tableView.computedProperties$.value];
const [column] = columns.splice(columnIndex, 1);
if (!column) return {};
const index = insertPositionToIndex(position, columns);
columns.splice(index, 0, column);
return {
columns,
};
});
}
hide$ = computed(() => {
const hideFromViewData = this.viewData$.value?.hide;
if (hideFromViewData != null) {
return hideFromViewData;
}
const defaultShow = this.meta$.value?.config.fixed?.defaultShow;
if (defaultShow != null) {
return !defaultShow;
}
return false;
});
statCalcOp$ = computed(() => {
return this.viewData$.value?.statCalcType;
});
width$: ReadonlySignal<number> = computed(() => {
const column = this.viewData$.value;
if (column?.width != null) {
return column.width;
}
const type = this.type$.value;
if (type === 'title') {
return 260;
}
return DEFAULT_COLUMN_WIDTH;
});
get minWidth() {
return this.meta$.value?.config.minWidth ?? DEFAULT_COLUMN_MIN_WIDTH;
}
constructor(
private readonly tableView: TableSingleView,
columnId: string
) {
super(tableView as SingleView, columnId);
}
viewDataUpdate(
updater: (viewData: TableColumnData) => Partial<TableColumnData>
): void {
this.tableView.dataUpdate(data => {
return {
...data,
columns: this.tableView.computedProperties$.value.map(v =>
v.id === this.id ? { ...v, ...updater(v) } : v
),
};
});
}
viewData$ = computed(() => {
return this.tableView.data$.value?.columns.find(v => v.id === this.id);
});
updateStatCalcOp(type?: string): void {
this.viewDataUpdate(data => {
return {
...data,
statCalcType: type,
};
});
}
updateWidth(width: number): void {
this.viewDataUpdate(data => {
return {
...data,
width,
};
});
}
}
export class TableRow extends RowBase {
override move(
position: InsertToPosition,
fromGroup?: string,
toGroup?: string
): void {
if (toGroup == null) {
super.move(position);
return;
}
this.tableView.groupTrait.moveCardTo(
this.rowId,
fromGroup,
toGroup,
position
);
}
constructor(
readonly tableView: TableSingleView,
rowId: string
) {
super(tableView, rowId);
}
}