mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 12:06:35 +08:00
a2a90df276
close: BS-3433 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced advanced group-by configurations for database blocks with user membership support. - Added a React hook for fetching and displaying user information in member-related components. - Enabled dynamic user and membership data types in database properties. - **Improvements** - Replaced context-based service access with a dependency injection system for shared services and state. - Enhanced type safety and consistency across group-by UI components and data handling. - Centralized group data management with a new Group class and refined group trait logic. - **Bug Fixes** - Improved reliability and consistency in retrieving and rendering user and group information. - **Style** - Removed obsolete member selection styles for cleaner UI code. - **Chores** - Registered external group-by configurations via dependency injection. - Refactored internal APIs for data sources, views, and group-by matchers to use service-based patterns. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
247 lines
6.5 KiB
TypeScript
247 lines
6.5 KiB
TypeScript
import {
|
|
menu,
|
|
type MenuConfig,
|
|
popMenu,
|
|
popupTargetFromElement,
|
|
} from '@blocksuite/affine-components/context-menu';
|
|
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
|
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
|
|
import { ShadowlessElement } from '@blocksuite/std';
|
|
import { Text } from '@blocksuite/store';
|
|
import { autoPlacement, offset } from '@floating-ui/dom';
|
|
import { computed, signal } from '@preact/signals-core';
|
|
import { css, html } from 'lit';
|
|
import { property } from 'lit/decorators.js';
|
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
|
|
import type { Group } from '../../../core/group-by/trait.js';
|
|
import { typeSystem } from '../../../core/index.js';
|
|
import { statsFunctions } from '../../../core/statistics/index.js';
|
|
import type { StatisticsConfig } from '../../../core/statistics/types.js';
|
|
import type { TableProperty } from '../table-view-manager.js';
|
|
|
|
const styles = css`
|
|
.stats-cell {
|
|
cursor: pointer;
|
|
transition: opacity 230ms ease;
|
|
font-size: 12px;
|
|
color: var(--affine-text-secondary-color);
|
|
display: flex;
|
|
opacity: 0;
|
|
justify-content: flex-end;
|
|
height: 100%;
|
|
align-items: center;
|
|
user-select: none;
|
|
}
|
|
|
|
affine-database-column-stats:hover .stats-cell {
|
|
opacity: 1;
|
|
}
|
|
|
|
.stats-cell:hover,
|
|
affine-database-column-stats-cell.active .stats-cell {
|
|
opacity: 1;
|
|
background-color: var(--affine-hover-color);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.stats-cell[calculated='true'] {
|
|
opacity: 1;
|
|
}
|
|
|
|
.stats-cell .content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.2rem;
|
|
margin-inline: 5px;
|
|
}
|
|
|
|
.label {
|
|
text-transform: uppercase;
|
|
color: var(--affine-text-secondary-color);
|
|
}
|
|
|
|
.value {
|
|
color: var(--affine-text-primary-color);
|
|
}
|
|
`;
|
|
|
|
export class DatabaseColumnStatsCell extends SignalWatcher(
|
|
WithDisposable(ShadowlessElement)
|
|
) {
|
|
static override styles = styles;
|
|
|
|
@property({ attribute: false })
|
|
accessor column!: TableProperty;
|
|
|
|
cellValues$ = computed(() => {
|
|
if (this.group) {
|
|
return this.group.rows.map(row => {
|
|
return this.column.valueGet(row.rowId);
|
|
});
|
|
}
|
|
return this.column.cells$.value.map(cell => cell.jsonValue$.value);
|
|
});
|
|
|
|
groups$ = computed(() => {
|
|
const groups: Record<string, Record<string, StatisticsConfig>> = {};
|
|
|
|
statsFunctions.forEach(func => {
|
|
if (!typeSystem.unify(this.column.dataType$.value, func.dataType)) {
|
|
return;
|
|
}
|
|
if (!groups[func.group]) {
|
|
groups[func.group] = {};
|
|
}
|
|
const oldFunc = groups[func.group]?.[func.type];
|
|
if (!oldFunc || typeSystem.unify(func.dataType, oldFunc.dataType)) {
|
|
if (!func.impl) {
|
|
delete groups[func.group]?.[func.type];
|
|
} else {
|
|
const group = groups[func.group];
|
|
if (group) {
|
|
group[func.type] = func;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return groups;
|
|
});
|
|
|
|
openMenu = (ev: MouseEvent) => {
|
|
const menus: MenuConfig[] = Object.entries(this.groups$.value).map(
|
|
([group, funcs]) => {
|
|
return menu.subMenu({
|
|
name: group,
|
|
options: {
|
|
items: Object.values(funcs).map(func => {
|
|
return menu.action({
|
|
isSelected: func.type === this.column.statCalcOp$.value,
|
|
name: func.menuName ?? func.type,
|
|
select: () => {
|
|
this.column.updateStatCalcOp(func.type);
|
|
},
|
|
});
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
);
|
|
popMenu(popupTargetFromElement(ev.currentTarget as HTMLElement), {
|
|
options: {
|
|
items: [
|
|
menu.action({
|
|
isSelected: !this.column.statCalcOp$.value,
|
|
name: 'None',
|
|
select: () => {
|
|
this.column.updateStatCalcOp();
|
|
},
|
|
}),
|
|
...menus,
|
|
],
|
|
},
|
|
middleware: [
|
|
autoPlacement({ allowedPlacements: ['top', 'bottom'] }),
|
|
offset(10),
|
|
],
|
|
});
|
|
};
|
|
|
|
statsFunc$ = computed(() => {
|
|
return Object.values(this.groups$.value)
|
|
.flatMap(group => Object.values(group))
|
|
.find(func => func.type === this.column.statCalcOp$.value);
|
|
});
|
|
|
|
values$ = signal<unknown[]>([]);
|
|
|
|
statsResult$ = computed(() => {
|
|
const meta = this.column.meta$.value;
|
|
if (!meta) {
|
|
return null;
|
|
}
|
|
const func = this.statsFunc$.value;
|
|
if (!func) {
|
|
return null;
|
|
}
|
|
return {
|
|
name: func.displayName,
|
|
value:
|
|
func.impl?.(this.values$.value, {
|
|
meta,
|
|
dataSource: this.column.view.manager.dataSource,
|
|
}) ?? '',
|
|
};
|
|
});
|
|
|
|
subscriptionMap = new Map<unknown, () => void>();
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
this.disposables.addFromEvent(this, 'click', this.openMenu);
|
|
this.disposables.add(
|
|
this.cellValues$.subscribe(values => {
|
|
const map = new Map<unknown, () => void>();
|
|
values.forEach(value => {
|
|
if (value instanceof Text) {
|
|
const unsub = this.subscriptionMap.get(value);
|
|
if (unsub) {
|
|
map.set(value, unsub);
|
|
this.subscriptionMap.delete(value);
|
|
} else {
|
|
const f = () => {
|
|
this.values$.value = [...this.cellValues$.value];
|
|
};
|
|
value.yText.observe(f);
|
|
map.set(value, () => {
|
|
value.yText.unobserve(f);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
this.subscriptionMap.forEach(unsub => {
|
|
unsub();
|
|
});
|
|
this.subscriptionMap = map;
|
|
this.values$.value = this.cellValues$.value;
|
|
})
|
|
);
|
|
this.disposables.add(() => {
|
|
this.subscriptionMap.forEach(unsub => {
|
|
unsub();
|
|
});
|
|
this.subscriptionMap.clear();
|
|
});
|
|
}
|
|
|
|
protected override render() {
|
|
const style = {
|
|
width: `${this.column.width$.value}px`,
|
|
};
|
|
return html` <div
|
|
calculated="${!!this.column.statCalcOp$.value}"
|
|
style="${styleMap(style)}"
|
|
class="stats-cell"
|
|
>
|
|
<div class="content">
|
|
${!this.statsResult$.value
|
|
? html`Calculate ${ArrowDownSmallIcon()}`
|
|
: html`
|
|
<span class="label">${this.statsResult$.value.name}</span>
|
|
<span class="value">${this.statsResult$.value.value} </span>
|
|
`}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
@property({ attribute: false })
|
|
accessor group: Group | undefined = undefined;
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'affine-database-column-stats-cell': DatabaseColumnStatsCell;
|
|
}
|
|
}
|