feat(editor): add grouping support for member property of the database block (#12243)

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 -->
This commit is contained in:
zzj3720
2025-05-13 13:53:37 +00:00
parent fe2fc892df
commit a2a90df276
59 changed files with 702 additions and 374 deletions

View File

@@ -1,5 +1,11 @@
import type { ColumnDataType } from '@blocksuite/affine-model';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import {
Container,
createScope,
type GeneralServiceIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import type { TypeInstance } from '../logical/type.js';
@@ -8,7 +14,6 @@ import type { DatabaseFlags } from '../types.js';
import type { ViewConvertConfig } from '../view/convert.js';
import type { DataViewDataType, ViewMeta } from '../view/data-view.js';
import type { ViewManager } from '../view-manager/view-manager.js';
import type { DataViewContextKey } from './context.js';
export interface DataSource {
readonly$: ReadonlySignal<boolean>;
@@ -65,7 +70,9 @@ export interface DataSource {
propertyDelete(id: string): void;
propertyCanDelete(propertyId: string): boolean;
contextGet<T>(key: DataViewContextKey<T>): T;
provider: ServiceProvider;
serviceGet<T>(key: GeneralServiceIdentifier<T>): T | null;
serviceGetOrCreate<T>(key: GeneralServiceIdentifier<T>, create: () => T): T;
viewConverts: ViewConvertConfig[];
viewManager: ViewManager;
@@ -91,6 +98,8 @@ export interface DataSource {
viewMetaGetById$(viewId: string): ReadonlySignal<ViewMeta | undefined>;
}
export const DataSourceScope = createScope('data-source');
export abstract class DataSourceBase implements DataSource {
propertyTypeCanSet(propertyId: string): boolean {
return !this.isFixedProperty(propertyId);
@@ -101,7 +110,9 @@ export abstract class DataSourceBase implements DataSource {
propertyCanDelete(propertyId: string): boolean {
return !this.isFixedProperty(propertyId);
}
context = new Map<symbol, unknown>();
protected container = new Container();
abstract get parentProvider(): ServiceProvider;
abstract featureFlags$: ReadonlySignal<DatabaseFlags>;
@@ -144,12 +155,26 @@ export abstract class DataSourceBase implements DataSource {
return computed(() => this.cellValueGet(rowId, propertyId));
}
contextGet<T>(key: DataViewContextKey<T>): T {
return (this.context.get(key.key) as T) ?? key.defaultValue;
get provider() {
return this.container.provider(DataSourceScope, this.parentProvider);
}
contextSet<T>(key: DataViewContextKey<T>, value: T): void {
this.context.set(key.key, value);
serviceGet<T>(key: GeneralServiceIdentifier<T>): T | null {
return this.provider.getOptional(key);
}
serviceSet<T>(key: GeneralServiceIdentifier<T>, value: T): void {
this.container.addValue(key, value, { scope: DataSourceScope });
}
serviceGetOrCreate<T>(key: GeneralServiceIdentifier<T>, create: () => T): T {
const result = this.serviceGet(key);
if (result != null) {
return result;
}
const value = create();
this.serviceSet(key, value);
return value;
}
abstract propertyAdd(

View File

@@ -1,12 +0,0 @@
export interface DataViewContextKey<T> {
key: symbol;
defaultValue: T;
}
export const createContextKey = <T>(
name: string,
defaultValue: T
): DataViewContextKey<T> => ({
key: Symbol(name),
defaultValue,
});

View File

@@ -1,2 +1 @@
export * from './base.js';
export * from './context.js';

View File

@@ -1,7 +1,7 @@
import type { GroupBy } from '../common/types.js';
import type { DataSource } from '../data-source/index.js';
import type { PropertyMetaConfig } from '../property/property-config.js';
import { groupByMatcher } from './matcher.js';
import { getGroupByService } from './matcher.js';
export const defaultGroupBy = (
dataSource: DataSource,
@@ -9,7 +9,8 @@ export const defaultGroupBy = (
propertyId: string,
data: NonNullable<unknown>
): GroupBy | undefined => {
const name = groupByMatcher.match(
const groupByService = getGroupByService(dataSource);
const name = groupByService?.matcher.match(
propertyMeta.config.jsonValue.type({ data, dataSource })
)?.name;
return name != null

View File

@@ -1,6 +1,6 @@
import hash from '@emotion/hash';
import { MatcherCreator } from '../logical/matcher.js';
import type { TypeInstance } from '../logical/type.js';
import { t } from '../logical/type-presets.js';
import { createUniComponentFromWebComponent } from '../utils/uni-component/uni-component.js';
import { BooleanGroupView } from './renderer/boolean-group.js';
@@ -8,15 +8,23 @@ import { NumberGroupView } from './renderer/number-group.js';
import { SelectGroupView } from './renderer/select-group.js';
import { StringGroupView } from './renderer/string-group.js';
import type { GroupByConfig } from './types.js';
const groupByMatcherCreator = new MatcherCreator<GroupByConfig>();
const ungroups = {
export const createGroupByConfig = <
Data extends Record<string, unknown>,
MatchType extends TypeInstance,
GroupValue = unknown,
>(
config: GroupByConfig<Data, MatchType, GroupValue>
): GroupByConfig => {
return config as never as GroupByConfig;
};
export const ungroups = {
key: 'Ungroups',
value: null,
};
export const groupByMatchers = [
groupByMatcherCreator.createMatcher(t.tag.instance(), {
createGroupByConfig({
name: 'select',
matchType: t.tag.instance(),
groupName: (type, value) => {
if (t.tag.is(type) && type.data) {
return type.data.find(v => v.id === value)?.value ?? '';
@@ -48,11 +56,12 @@ export const groupByMatchers = [
},
view: createUniComponentFromWebComponent(SelectGroupView),
}),
groupByMatcherCreator.createMatcher(t.array.instance(t.tag.instance()), {
createGroupByConfig({
name: 'multi-select',
groupName: (type, value) => {
if (t.tag.is(type) && type.data) {
return type.data.find(v => v.id === value)?.value ?? '';
matchType: t.array.instance(t.tag.instance()),
groupName: (type, value: string | null) => {
if (t.array.is(type) && t.tag.is(type.element) && type.element.data) {
return type.element.data.find(v => v.id === value)?.value ?? '';
}
return '';
},
@@ -94,8 +103,9 @@ export const groupByMatchers = [
},
view: createUniComponentFromWebComponent(SelectGroupView),
}),
groupByMatcherCreator.createMatcher(t.string.instance(), {
createGroupByConfig({
name: 'text',
matchType: t.string.instance(),
groupName: (_type, value) => {
return `${value ?? ''}`;
},
@@ -115,15 +125,16 @@ export const groupByMatchers = [
},
view: createUniComponentFromWebComponent(StringGroupView),
}),
groupByMatcherCreator.createMatcher(t.number.instance(), {
createGroupByConfig({
name: 'number',
groupName: (_type, value) => {
matchType: t.number.instance(),
groupName: (_type, value: number | null) => {
return `${value ?? ''}`;
},
defaultKeys: _type => {
return [ungroups];
},
valuesGroup: (value, _type) => {
valuesGroup: (value: number | null, _type) => {
if (typeof value !== 'number') {
return [ungroups];
}
@@ -137,8 +148,9 @@ export const groupByMatchers = [
addToGroup: value => (typeof value === 'number' ? value * 10 : null),
view: createUniComponentFromWebComponent(NumberGroupView),
}),
groupByMatcherCreator.createMatcher(t.boolean.instance(), {
createGroupByConfig({
name: 'boolean',
matchType: t.boolean.instance(),
groupName: (_type, value) => {
return `${value?.toString() ?? ''}`;
},

View File

@@ -5,10 +5,10 @@ import { nothing } from 'lit';
import { html } from 'lit/static-html.js';
import { renderUniLit } from '../utils/uni-component/uni-component.js';
import type { GroupData } from './trait.js';
import type { Group } from './trait.js';
import type { GroupRenderProps } from './types.js';
function GroupHeaderCount(group: GroupData) {
function GroupHeaderCount(group: Group) {
const cards = group.rows;
if (!cards.length) {
return;
@@ -16,32 +16,25 @@ function GroupHeaderCount(group: GroupData) {
return html` <div class="group-header-count">${cards.length}</div>`;
}
const GroupTitleMobile = (
groupData: GroupData,
groupData: Group,
ops: {
readonly: boolean;
clickAdd: (evt: MouseEvent) => void;
clickOps: (evt: MouseEvent) => void;
}
) => {
const data = groupData.manager.config$.value;
if (!data) return nothing;
const type = groupData.tType;
if (!type) return nothing;
const icon =
groupData.value == null
? ''
: html` <uni-lit
class="group-header-icon"
.uni="${groupData.manager.property$.value?.icon}"
.uni="${groupData.property.icon}"
></uni-lit>`;
const props: GroupRenderProps = {
value: groupData.value,
data: groupData.property.data$.value,
updateData: groupData.manager.updateData,
updateValue: value =>
groupData.manager.updateValue(
groupData.rows.map(row => row.rowId),
value
),
group: groupData,
readonly: ops.readonly,
};
@@ -103,7 +96,7 @@ const GroupTitleMobile = (
<div
style="display:flex;align-items:center;gap: 8px;overflow: hidden;height: 22px;"
>
${icon} ${renderUniLit(data.view, props)} ${columnName}
${icon} ${renderUniLit(groupData.view, props)} ${columnName}
${GroupHeaderCount(groupData)}
</div>
${ops.readonly
@@ -120,7 +113,7 @@ const GroupTitleMobile = (
};
export const GroupTitle = (
groupData: GroupData,
groupData: Group,
ops: {
readonly: boolean;
clickAdd: (evt: MouseEvent) => void;
@@ -130,25 +123,18 @@ export const GroupTitle = (
if (IS_MOBILE) {
return GroupTitleMobile(groupData, ops);
}
const data = groupData.manager.config$.value;
if (!data) return nothing;
const type = groupData.tType;
if (!type) return nothing;
const icon =
groupData.value == null
? ''
: html` <uni-lit
class="group-header-icon"
.uni="${groupData.manager.property$.value?.icon}"
.uni="${groupData.property.icon}"
></uni-lit>`;
const props: GroupRenderProps = {
value: groupData.value,
data: groupData.property.data$.value,
updateData: groupData.manager.updateData,
updateValue: value =>
groupData.manager.updateValue(
groupData.rows.map(row => row.rowId),
value
),
group: groupData,
readonly: ops.readonly,
};
@@ -228,7 +214,7 @@ export const GroupTitle = (
<div
style="display:flex;align-items:center;gap: 8px;overflow: hidden;height: 22px;"
>
${icon} ${renderUniLit(data.view, props)} ${columnName}
${icon} ${renderUniLit(groupData.view, props)} ${columnName}
${GroupHeaderCount(groupData)}
</div>
${ops.readonly

View File

@@ -1 +1,3 @@
export * from './define.js';
export * from './matcher.js';
export * from './trait.js';

View File

@@ -1,5 +1,41 @@
import { Matcher } from '../logical/matcher.js';
import { createIdentifier } from '@blocksuite/global/di';
import type { DataSource } from '../data-source/base.js';
import { Matcher_ } from '../logical/matcher.js';
import { groupByMatchers } from './define.js';
import type { GroupByConfig } from './types.js';
export const groupByMatcher = new Matcher<GroupByConfig>(groupByMatchers);
export const createGroupByMatcher = (list: GroupByConfig[]) => {
return new Matcher_(list, v => v.matchType);
};
export class GroupByService {
constructor(private readonly dataSource: DataSource) {}
allExternalGroupByConfig(): GroupByConfig[] {
return Array.from(
this.dataSource.provider.getAll(ExternalGroupByConfigProvider).values()
);
}
get matcher() {
return createGroupByMatcher([
...this.allExternalGroupByConfig(),
...groupByMatchers,
]);
}
}
export const GroupByProvider =
createIdentifier<GroupByService>('group-by-service');
export const getGroupByService = (dataSource: DataSource) => {
return dataSource.serviceGetOrCreate(
GroupByProvider,
() => new GroupByService(dataSource)
);
};
export const ExternalGroupByConfigProvider = createIdentifier<GroupByConfig>(
'external-group-by-config'
);

View File

@@ -2,24 +2,39 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { property } from 'lit/decorators.js';
import type { Group } from '../trait.js';
import type { GroupRenderProps } from '../types.js';
export class BaseGroup<Data extends NonNullable<unknown>, Value>
export class BaseGroup<JsonValue, Data extends Record<string, unknown>>
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements GroupRenderProps<Data, Value>
implements GroupRenderProps<JsonValue, Data>
{
@property({ attribute: false })
accessor data!: Data;
accessor group!: Group<unknown, JsonValue, Data>;
@property({ attribute: false })
accessor readonly!: boolean;
@property({ attribute: false })
accessor updateData: ((data: Data) => void) | undefined = undefined;
updateData(data: Data) {
this.group.manager.updateData(data);
}
@property({ attribute: false })
accessor updateValue: ((value: Value) => void) | undefined = undefined;
updateValue(value: JsonValue) {
this.group.manager.updateValue(
this.group.rows.map(row => row.rowId),
value
);
}
@property({ attribute: false })
accessor value!: Value;
get value(): JsonValue {
return this.group.value as JsonValue;
}
get type() {
return this.group.tType;
}
get data() {
return this.group.property.data$.value;
}
}

View File

@@ -3,7 +3,7 @@ import { css, html } from 'lit';
import { BaseGroup } from './base.js';
export class BooleanGroupView extends BaseGroup<NonNullable<unknown>, boolean> {
export class BooleanGroupView extends BaseGroup<boolean, NonNullable<unknown>> {
static override styles = css`
.data-view-group-title-boolean-view {
display: flex;

View File

@@ -7,7 +7,7 @@ import { css, html } from 'lit';
import { BaseGroup } from './base.js';
export class NumberGroupView extends BaseGroup<NonNullable<unknown>, number> {
export class NumberGroupView extends BaseGroup<number, NonNullable<unknown>> {
static override styles = css`
.data-view-group-title-number-view {
border-radius: 8px;

View File

@@ -12,10 +12,10 @@ import type { SelectTag } from '../../logical/index.js';
import { BaseGroup } from './base.js';
export class SelectGroupView extends BaseGroup<
string,
{
options: SelectTag[];
},
string
}
> {
static override styles = css`
data-view-group-title-select-view {

View File

@@ -7,7 +7,7 @@ import { css, html } from 'lit';
import { BaseGroup } from './base.js';
export class StringGroupView extends BaseGroup<NonNullable<unknown>, string> {
export class StringGroupView extends BaseGroup<string, NonNullable<unknown>> {
static override styles = css`
.data-view-group-title-string-view {
border-radius: 8px;

View File

@@ -24,7 +24,7 @@ import {
sortable,
} from '../utils/wc-dnd/sort/sort-context.js';
import { verticalListSortingStrategy } from '../utils/wc-dnd/sort/strategies/index.js';
import { groupByMatcher } from './matcher.js';
import { getGroupByService } from './matcher.js';
import type { GroupTrait } from './trait.js';
import type { GroupRenderProps } from './types.js';
@@ -142,21 +142,22 @@ export class GroupSetting extends SignalWatcher(
groups,
group => group?.key ?? 'default key',
group => {
if (!group) return;
const type = group.property.dataType$.value;
if (!type) return;
const props: GroupRenderProps = {
value: group.value,
data: group.property.data$.value,
group,
readonly: true,
};
const config = group.manager.config$.value;
return html` <div
${sortable(group.key)}
${dragHandler(group.key)}
class="dv-hover dv-round-4 group-item"
>
<div class="group-item-drag-bar"></div>
<div style="padding: 0 4px;position:relative;">
${renderUniLit(config?.view, props)}
<div
style="padding: 0 4px;position:relative;pointer-events: none"
>
${renderUniLit(group.view, props)}
<div
style="position:absolute;left: 0;top: 0;right: 0;bottom: 0;"
></div>
@@ -198,7 +199,8 @@ export const selectGroupByProperty = (
if (!dataType) {
return false;
}
return !!groupByMatcher.match(dataType);
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property => {
return menu.action({

View File

@@ -12,89 +12,112 @@ import type { Property } from '../view-manager/property.js';
import type { Row } from '../view-manager/row.js';
import type { SingleView } from '../view-manager/single-view.js';
import { defaultGroupBy } from './default.js';
import { groupByMatcher } from './matcher.js';
export type GroupData = {
manager: GroupTrait;
property: Property;
key: string;
name: string;
type: TypeInstance;
value: unknown;
rows: Row[];
import { getGroupByService } from './matcher.js';
import type { GroupByConfig } from './types.js';
export type GroupInfo<
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> = {
config: GroupByConfig;
property: Property<RawValue, JsonValue, Data>;
tType: TypeInstance;
};
export class Group<
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> {
rows: Row[] = [];
constructor(
public readonly key: string,
public readonly value: JsonValue,
private readonly groupInfo: GroupInfo<RawValue, JsonValue, Data>,
public readonly manager: GroupTrait
) {}
get property() {
return this.groupInfo.property;
}
name$ = computed(() => {
const type = this.property.dataType$.value;
if (!type) {
return '';
}
return this.groupInfo.config.groupName(type, this.value);
});
private get config() {
return this.groupInfo.config;
}
get tType() {
return this.groupInfo.tType;
}
get view() {
return this.config.view;
}
}
export class GroupTrait {
config$ = computed(() => {
groupInfo$ = computed<GroupInfo | undefined>(() => {
const groupBy = this.groupBy$.value;
if (!groupBy) {
return;
}
const result = groupByMatcher.find(v => v.data.name === groupBy.name);
const property = this.view.propertyGetOrCreate(groupBy.columnId);
if (!property) {
return;
}
const tType = property.dataType$.value;
if (!tType) {
return;
}
const groupByService = getGroupByService(this.view.manager.dataSource);
const result = groupByService?.matcher.match(tType);
if (!result) {
return;
}
return result.data;
return {
config: result,
property,
tType: tType,
};
});
property$ = computed(() => {
const groupBy = this.groupBy$.value;
if (!groupBy) {
staticInfo$ = computed(() => {
const groupInfo = this.groupInfo$.value;
if (!groupInfo) {
return;
}
return this.view.propertyGetOrCreate(groupBy.columnId);
});
staticGroupDataMap$ = computed<
Record<string, Omit<GroupData, 'rows'>> | undefined
>(() => {
const config = this.config$.value;
const property = this.property$.value;
const tType = property?.dataType$.value;
if (!config || !tType || !property) {
return;
}
return Object.fromEntries(
config.defaultKeys(tType).map(({ key, value }) => [
key,
{
key,
property,
name: config.groupName(tType, value),
manager: this,
type: tType,
value,
},
])
const staticMap = Object.fromEntries(
groupInfo.config
.defaultKeys(groupInfo.tType)
.map(({ key, value }) => [key, new Group(key, value, groupInfo, this)])
);
return {
staticMap,
groupInfo,
};
});
groupDataMap$ = computed<Record<string, GroupData> | undefined>(() => {
const staticGroupMap = this.staticGroupDataMap$.value;
const config = this.config$.value;
const groupBy = this.groupBy$.value;
const property = this.property$.value;
const tType = property?.dataType$.value;
if (!staticGroupMap || !config || !groupBy || !tType || !property) {
groupDataMap$ = computed(() => {
const staticInfo = this.staticInfo$.value;
if (!staticInfo) {
return;
}
const groupMap: Record<string, GroupData> = Object.fromEntries(
Object.entries(staticGroupMap).map(([k, v]) => [k, { ...v, rows: [] }])
);
const { staticMap, groupInfo } = staticInfo;
const groupMap: Record<string, Group> = { ...staticMap };
this.view.rows$.value.forEach(row => {
const value = this.view.cellGetOrCreate(row.rowId, groupBy.columnId)
const value = this.view.cellGetOrCreate(row.rowId, groupInfo.property.id)
.jsonValue$.value;
const keys = config.valuesGroup(value, tType);
const keys = groupInfo.config.valuesGroup(value, groupInfo.tType);
keys.forEach(({ key, value }) => {
if (!groupMap[key]) {
groupMap[key] = {
key,
property: property,
name: config.groupName(tType, value),
manager: this,
value,
rows: [],
type: tType,
};
groupMap[key] = new Group(key, value, groupInfo, this);
}
groupMap[key].rows.push(row);
});
@@ -115,30 +138,30 @@ export class GroupTrait {
});
return sortedGroup
.map(key => groupMap[key])
.filter((v): v is GroupData => v != null);
.filter((v): v is Group => v != null);
}),
this.view.isLocked$
);
updateData = (data: NonNullable<unknown>) => {
const propertyId = this.propertyId;
if (!propertyId) {
const property = this.property$.value;
if (!property) {
return;
}
this.view.propertyGetOrCreate(propertyId).dataUpdate(() => data);
this.view.propertyGetOrCreate(property.id).dataUpdate(() => data);
};
get addGroup() {
const type = this.property$.value?.type$.value;
if (!type) {
return;
}
return this.view.manager.dataSource.propertyMetaGet(type)?.config.addGroup;
return this.property$.value?.meta$.value?.config.addGroup;
}
get propertyId() {
return this.groupBy$.value?.columnId;
}
property$ = computed(() => {
const groupInfo = this.groupInfo$.value;
if (!groupInfo) {
return;
}
return groupInfo.property;
});
constructor(
private readonly groupBy$: ReadonlySignal<GroupBy | undefined>,
@@ -158,18 +181,20 @@ export class GroupTrait {
addToGroup(rowId: string, key: string) {
const groupMap = this.groupDataMap$.value;
const propertyId = this.propertyId;
if (!groupMap || !propertyId) {
const groupInfo = this.groupInfo$.value;
if (!groupMap || !groupInfo) {
return;
}
const addTo = this.config$.value?.addToGroup ?? (value => value);
const addTo = groupInfo.config.addToGroup ?? (value => value);
const v = groupMap[key]?.value;
if (v != null) {
const newValue = addTo(
v,
this.view.cellGetOrCreate(rowId, propertyId).jsonValue$.value
this.view.cellGetOrCreate(rowId, groupInfo.property.id).jsonValue$.value
);
this.view.cellGetOrCreate(rowId, propertyId).valueSet(newValue);
this.view
.cellGetOrCreate(rowId, groupInfo.property.id)
.valueSet(newValue);
}
}
@@ -229,11 +254,12 @@ export class GroupTrait {
return;
}
if (fromGroupKey !== toGroupKey) {
const propertyId = this.propertyId;
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;
}
const remove = this.config$.value?.removeFromGroup ?? (() => null);
const remove =
this.groupInfo$.value?.config.removeFromGroup ?? (() => null);
const group = fromGroupKey != null ? groupMap[fromGroupKey] : undefined;
let newValue: unknown = null;
if (group) {
@@ -242,7 +268,8 @@ export class GroupTrait {
this.view.cellGetOrCreate(rowId, propertyId).jsonValue$.value
);
}
const addTo = this.config$.value?.addToGroup ?? (value => value);
const addTo =
this.groupInfo$.value?.config.addToGroup ?? (value => value);
newValue = addTo(groupMap[toGroupKey]?.value ?? null, newValue);
this.view.cellGetOrCreate(rowId, propertyId).jsonValueSet(newValue);
}
@@ -275,11 +302,12 @@ export class GroupTrait {
if (!groupMap) {
return;
}
const propertyId = this.propertyId;
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;
}
const remove = this.config$.value?.removeFromGroup ?? (() => undefined);
const remove =
this.groupInfo$.value?.config.removeFromGroup ?? (() => undefined);
const newValue = remove(
groupMap[key]?.value ?? null,
this.view.cellGetOrCreate(rowId, propertyId).jsonValue$.value
@@ -288,7 +316,7 @@ export class GroupTrait {
}
updateValue(rows: string[], value: unknown) {
const propertyId = this.propertyId;
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;
}

View File

@@ -1,35 +1,41 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { TypeInstance } from '../logical/type.js';
import type { TypeInstance, ValueTypeOf } from '../logical/type.js';
import type { Group } from './trait.js';
export interface GroupRenderProps<
Data extends NonNullable<unknown> = NonNullable<unknown>,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> {
data: Data;
updateData?: (data: Data) => void;
value: JsonValue;
updateValue?: (value: JsonValue) => void;
group: Group<unknown, JsonValue, Data>;
readonly: boolean;
}
export type GroupByConfig<
JsonValue = unknown,
Data extends NonNullable<unknown> = NonNullable<unknown>,
MatchType extends TypeInstance = TypeInstance,
GroupValue = unknown,
> = {
name: string;
groupName: (type: TypeInstance, value: unknown) => string;
defaultKeys: (type: TypeInstance) => {
matchType: MatchType;
groupName: (type: MatchType, value: GroupValue | null) => string;
defaultKeys: (type: MatchType) => {
key: string;
value: JsonValue;
value: GroupValue | null;
}[];
valuesGroup: (
value: unknown,
type: TypeInstance
value: ValueTypeOf<MatchType> | null,
type: MatchType
) => {
key: string;
value: JsonValue;
value: GroupValue | null;
}[];
addToGroup?: (value: JsonValue, oldValue: JsonValue) => JsonValue;
removeFromGroup?: (value: JsonValue, oldValue: JsonValue) => JsonValue;
view: UniComponent<GroupRenderProps<Data, JsonValue>>;
addToGroup?: (
value: GroupValue | null,
oldValue: ValueTypeOf<MatchType> | null
) => ValueTypeOf<MatchType> | null;
removeFromGroup?: (
value: GroupValue | null,
oldValue: ValueTypeOf<MatchType> | null
) => ValueTypeOf<MatchType> | null;
view: UniComponent<GroupRenderProps<GroupValue | null, Data>>;
};

View File

@@ -68,3 +68,46 @@ export class Matcher<Data, Type extends TypeInstance = TypeInstance> {
return;
}
}
export class Matcher_<Value, Type extends TypeInstance> {
constructor(
private readonly list: Value[],
private readonly getType: (value: Value) => Type,
private readonly matchFunc: (
type: Type,
target: TypeInstance
) => boolean = (type, target) => typeSystem.unify(target, type)
) {}
all(): Value[] {
return this.list;
}
allMatched(type: TypeInstance): Value[] {
const result: Value[] = [];
for (const t of this.list) {
const tType = this.getType(t);
if (this.matchFunc(tType, type)) {
result.push(t);
}
}
return result;
}
find(f: (data: Value) => boolean): Value | undefined {
return this.list.find(f);
}
isMatched(type: Type, target: TypeInstance) {
return this.matchFunc(type, target);
}
match(type: TypeInstance) {
for (const t of this.list) {
const tType = this.getType(t);
if (this.matchFunc(tType, type)) {
return t;
}
}
return;
}
}

View File

@@ -1,3 +1,7 @@
import type {
UserListService,
UserService,
} from '@blocksuite/affine-shared/services';
import * as zod from 'zod';
import Zod from 'zod';
@@ -12,6 +16,11 @@ export const SelectTagSchema = Zod.object({
color: Zod.string(),
value: Zod.string(),
});
export const UserInfoSchema = Zod.object({
userService: Zod.custom<UserService>(() => true),
userListService: Zod.custom<UserListService>(() => true),
});
export type UserInfo = Zod.TypeOf<typeof UserInfoSchema>;
export const unknown = defineDataType('Unknown', zod.never(), zod.unknown());
export const dt = {
number: defineDataType('Number', zod.number(), zod.number()),
@@ -22,6 +31,7 @@ export const dt = {
url: defineDataType('URL', zod.string(), zod.string()),
image: defineDataType('Image', zod.string(), zod.string()),
tag: defineDataType('Tag', zod.array(SelectTagSchema), zod.string()),
user: defineDataType('User', UserInfoSchema, zod.string()),
};
export const t = {
unknown,
@@ -53,4 +63,5 @@ export const converts: TypeConvertConfig[] = [
),
createTypeConvert(t.richText.instance(), t.string.instance(), value => value),
createTypeConvert(t.url.instance(), t.string.instance(), value => value),
createTypeConvert(t.user.instance(), t.string.instance(), value => value),
];

View File

@@ -1,7 +1,7 @@
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import type { GeneralServiceIdentifier } from '@blocksuite/global/di';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import type { DataViewContextKey } from '../data-source/context.js';
import type { Variable } from '../expression/types.js';
import type { PropertyMetaConfig } from '../property/property-config.js';
import type { TraitKey } from '../traits/key.js';
@@ -61,7 +61,8 @@ export interface SingleView {
type?: string
): string | undefined;
contextGet<T>(key: DataViewContextKey<T>): T;
serviceGet<T>(key: GeneralServiceIdentifier<T>): T | null;
serviceGetOrCreate<T>(key: GeneralServiceIdentifier<T>, create: () => T): T;
traitGet<T>(key: TraitKey<T>): T | undefined;
@@ -201,8 +202,12 @@ export abstract class SingleViewBase<
return new CellBase(this, propertyId, rowId);
}
contextGet<T>(key: DataViewContextKey<T>): T {
return this.dataSource.contextGet(key);
serviceGet<T>(key: GeneralServiceIdentifier<T>): T | null {
return this.dataSource.serviceGet(key);
}
serviceGetOrCreate<T>(key: GeneralServiceIdentifier<T>, create: () => T): T {
return this.dataSource.serviceGetOrCreate(key, create);
}
dataUpdate(updater: (viewData: ViewData) => Partial<ViewData>): void {